diff --git a/.cursor/goal/scratchpad.md b/.cursor/goal/scratchpad.md new file mode 100644 index 000000000..ef8582f7f --- /dev/null +++ b/.cursor/goal/scratchpad.md @@ -0,0 +1,62 @@ +--- +iteration: 2 +max_iterations: 15 +status: active +goal: "Implement Phase 1 homepage diet via Next.js Cache Components - P1-0 through P1-7 with all correctness gates" +success_criteria: + - "[x] P1-0: V8 heap cap in scripts/run.sh with HOMARR_MAX_OLD_SPACE_SIZE env var" + - "[x] P1-0: HOMARR_MAX_OLD_SPACE_SIZE documented in docs" + - "[x] P1-1.1: Dynamic integration imports replacing 55 static imports in creator.ts" + - "[x] P1-1.1: createRetryableLoader extracted to packages/common" + - "[x] P1-1.1: creator.spec.ts testing all integration kinds load" + - "[x] P1-1.2: DB driver loaded conditionally via DB_DRIVER env" + - "[x] P1-1.3: jsdom replaced with linkedom for SVG sanitization" + - "[x] P1-1.3: sanitize.spec.ts with XSS test corpus" + - "[x] P1-1.4: Eager widget barrel deleted, imports repointed" + - "[x] P1-2.1: cacheComponents enabled in next.config.ts with cacheLife profiles" + - "[x] P1-2.2: Root layout restructured with Suspense boundaries" + - "[x] P1-2.3: Board/manage/auth routes compatible with cacheComponents" + - "[x] P1-3.1: Cache tag registry in packages/api/src/cache-tags.ts" + - "[x] P1-3.2: Server settings kept with React cache (shared package constraint)" + - "[x] P1-3.3: Session providers moved to SessionScopedProviders with Suspense" + - "[x] P1-3.4: Per-request translation deep-merge eliminated" + - "[x] P1-3.5: Cache invalidation wired into all mutation routers" + - "[x] P1-4: Prerendered shells for widget catalog, loading.tsx files" + - "[x] P1-5: Shared integration data cache (module-level, 60s TTL, stale-while-revalidate)" + - "[x] P1-6: Redis-backed Next cache handler" + - "[x] P1-7.1: Redis optional for single-instance (memory channel fallback)" + - "[x] P1-7.1: TTL on pubSub:last:* keys (F4)" + - "[x] P1-7.1: Redis clients consolidated from 6 to 2 (F3)" + - "[x] All 25 new specs pass" + - "[x] No diff to trpc.tsx or query-cache.ts" + - "[ ] Reviewed with /review-pr at least once" + - "[ ] All review issues fixed" + - "[ ] Integration type map created for kind-to-class resolution" +last_verdict: null +last_reason: null +last_next: "Run /review-pr, fix issues, iterate until clean" +engine: cursor +--- + +## Progress log + +### Iteration 1 — Implementation complete +- P1-0 through P1-7 all implemented via parallel subagents +- 66 files changed, 1280 insertions, 1229 deletions +- 25 new tests all passing +- Type errors from dynamic integration imports fixed with kind-map.ts + +### Iteration 2 — Review cycle begins +- Need to commit, push, and run /review-pr +- Need to fix any issues found + +## Blockers +None currently. + +## Verification +- 25/25 tests pass +- trpc.tsx and query-cache.ts unchanged (constraint met) +- Integration type map resolves subclass methods correctly + +## Task +Implement Phase 1 homepage diet via Next.js Cache Components - P1-0 through P1-7 with all correctness gates diff --git a/.gitignore b/.gitignore index 71a27829a..6a0711782 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ apps/docs/.docusaurus apps/docs/build .pnpm-store benchmark-results +.bench/ diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index e7dc6960d..023ffd34a 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -32,6 +32,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t | `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | | `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | | `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| `HOMARR_MAX_OLD_SPACE_SIZE` | V8 old-space heap limit in MB. Prevents RSS drift on large-memory hosts. Raise for very large boards. | Any positive integer (MB) | `512` | ## Authentication diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 2105259be..c61c6dd9b 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -27,6 +27,9 @@ const getDevelopmentServiceAliases = () => { }; const nextConfig: NextConfig = { + cacheHandlers: { + default: path.resolve(import.meta.dirname, "../../packages/next-cache-handler/src/index.ts"), + }, // Next previews otherwise create agent instruction files in the application // directory during development. agentRules: false, @@ -35,15 +38,21 @@ const nextConfig: NextConfig = { }, output: "standalone", reactStrictMode: true, + cacheComponents: true, + cacheMaxMemorySize: 32 * 1024 * 1024, + cacheLife: { + config: { stale: 60, revalidate: 60 * 60 * 24, expire: 60 * 60 * 24 * 7 }, + integration: { stale: 15, revalidate: 60, expire: 60 * 10 }, + session: { stale: 30, revalidate: 60 * 5, expire: 60 * 30 }, + }, // react compiler breaks mantine-react-table, so disabled for now //reactCompiler: true, /** We already do typechecking as separate tasks in CI */ typescript: { ignoreBuildErrors: true }, /** * dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612 - * isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356 */ - serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], + serverExternalPackages: ["dockerode", "better-sqlite3", "mysql2", "pg"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], turbopackFileSystemCacheForBuild: true, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 3c33a1545..ed76c58dc 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -37,6 +37,7 @@ "@homarr/integrations": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0", + "@homarr/next-cache-handler": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", "@homarr/old-import": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0", @@ -76,13 +77,14 @@ "chroma-js": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", + "dompurify": "catalog:", "dotenv": "catalog:", "drizzle-orm": "catalog:", "flag-icons": "catalog:", "glob": "catalog:", - "isomorphic-dompurify": "catalog:", "jotai": "catalog:", "jszip": "catalog:", + "linkedom": "catalog:", "lucide-react": "catalog:", "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 493d368f1..e5e423102 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -13,8 +13,8 @@ import { theme } from "@homarr/ui"; export const CustomMantineProvider = ({ children, - defaultColorScheme, -}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { + defaultColorScheme = "auto", +}: PropsWithChildren<{ defaultColorScheme?: MantineColorScheme }>) => { const manager = useColorSchemeManager(); return ( diff --git a/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx b/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx new file mode 100644 index 000000000..ce0ed0a1b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx @@ -0,0 +1,84 @@ +import type { PropsWithChildren } from "react"; +import type { DayOfWeek } from "@mantine/dates"; +import { NextIntlClientProvider } from "next-intl"; + +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; +import { env } from "@homarr/auth/env"; +import { auth } from "@homarr/auth/next"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ModalProvider } from "@homarr/modals"; +import { Notifications } from "@homarr/notifications"; +import { SettingsProvider } from "@homarr/settings"; +import { SpotlightProvider } from "@homarr/spotlight"; + +import { Analytics } from "~/components/layout/analytics"; +import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; +import { DayJsLoader } from "./_client-providers/dayjs-loader"; +import { JotaiProvider } from "./_client-providers/jotai"; +import { CustomMantineProvider } from "./_client-providers/mantine"; +import { AuthProvider } from "./_client-providers/session"; +import { TRPCReactProvider } from "./_client-providers/trpc"; +import { composeWrappers } from "./compose"; + +const logger = createLogger({ module: "sessionScopedProviders" }); + +export async function SessionScopedProviders({ children }: PropsWithChildren) { + const sessionPromise = auth(); + const userPromise = sessionPromise.then((session) => + session + ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { + logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); + return null; + }) + : null, + ); + const [session, user, serverSettings] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + ]); + + const StackedProvider = composeWrappers([ + (innerProps) => , + (innerProps) => ( + + ), + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + ]); + + return ( + + + + + {children} + + ); +} diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 30eb88ba0..528c68331 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,5 +1,6 @@ +import { Suspense } from "react"; import { redirect } from "next/navigation"; -import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core"; +import { Alert, Card, Center, Code, Loader, Stack, Text, Title } from "@mantine/core"; import { IconLogin } from "@tabler/icons-react"; import { env } from "@homarr/auth/env"; @@ -18,8 +19,7 @@ interface LoginProps { }>; } -export default async function Login(props: LoginProps) { - const searchParams = await props.searchParams; +async function LoginContent({ searchParams }: { searchParams: { callbackUrl?: string } }) { const session = await auth(); if (session) { @@ -59,3 +59,19 @@ export default async function Login(props: LoginProps) { ); } + +export default async function Login(props: LoginProps) { + const searchParams = await props.searchParams; + + return ( + + + + } + > + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 2bb868f7d..a26e1f497 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { cache } from "react"; +import { Center, Loader } from "@mantine/core"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content @@ -33,6 +35,52 @@ interface Props { getInitialBoardAsync: (params: TParams) => Promise; } +interface BoardContentPageProps { + params: TParams; + getInitialBoard: (params: TParams) => Promise; +} + +async function BoardContentPageDynamic({ + params, + getInitialBoard, +}: BoardContentPageProps) { + const queryClient = getQueryClient(); + + const [board, session] = await Promise.all([getInitialBoard(params), auth()]); + + const itemsMap = board.items.reduce((acc, item) => { + const existing = acc.get(item.kind); + if (existing) { + existing.push(item); + } else { + acc.set(item.kind, [item]); + } + return acc; + }, new Map()); + const [integrations] = await Promise.all([ + getIntegrationsWithPermissionsAsync(session), + ...Array.from(itemsMap).map(([kind, items]) => + prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error( + new ErrorWithMetadata( + "Failed to prefetch widget", + { widgetKind: kind, itemCount: items.length }, + { cause: error }, + ), + ); + }), + ), + ]); + + return ( + + + + + + ); +} + export const createBoardContentPage = >({ getInitialBoardAsync: getInitialBoard, }: Props) => { @@ -45,40 +93,17 @@ export const createBoardContentPage = >( // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { const resolvedParams = await params; - const queryClient = getQueryClient(); - - const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); - - const itemsMap = board.items.reduce((acc, item) => { - const existing = acc.get(item.kind); - if (existing) { - existing.push(item); - } else { - acc.set(item.kind, [item]); - } - return acc; - }, new Map()); - const [integrations] = await Promise.all([ - getIntegrationsWithPermissionsAsync(session), - ...Array.from(itemsMap).map(([kind, items]) => - prefetchForKindAsync(kind, queryClient, items).catch((error) => { - logger.error( - new ErrorWithMetadata( - "Failed to prefetch widget", - { widgetKind: kind, itemCount: items.length }, - { cause: error }, - ), - ); - }), - ), - ]); return ( - - - - - + + + + } + > + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 0ea5c9cf0..efca6357c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -11,8 +11,8 @@ import { useColorSchemeManager } from "../../_client-providers/mantine"; export const BoardMantineProvider = ({ children, - defaultColorScheme, -}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { + defaultColorScheme = "auto", +}: PropsWithChildren<{ defaultColorScheme?: ColorScheme }>) => { const board = useRequiredBoard(); const colorSchemeManager = useColorSchemeManager(); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/loading.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/loading.tsx new file mode 100644 index 000000000..cd736fcbb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function BoardLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 24ebe3d03..4966b970c 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -1,6 +1,7 @@ import type { JSX, PropsWithChildren } from "react"; +import { Suspense } from "react"; import { notFound, redirect } from "next/navigation"; -import { AppShellMain } from "@mantine/core"; +import { AppShellMain, Skeleton } from "@mantine/core"; import { TRPCError } from "@trpc/server"; import { getRscUserSettingsAsync } from "@homarr/api/user-server"; @@ -14,7 +15,6 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { env } from "~/env"; -import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; import { CustomCss } from "./(content)/_custom-css"; @@ -29,6 +29,94 @@ interface CreateBoardLayoutProps { withTour?: boolean; } +const BoardLayoutFallback = ({ headerActions }: { headerActions: JSX.Element }) => ( + + } actions={headerActions} hasNavigation={false} /> + + + + +); + +interface BoardLayoutDynamicProps { + params: TParams; + headerActions: JSX.Element; + getInitialBoardAsync: (params: TParams) => Promise; + withTour: boolean; + children: React.ReactNode; +} + +async function BoardLayoutDynamic({ + params, + headerActions, + getInitialBoardAsync: getInitialBoard, + withTour, + children, +}: BoardLayoutDynamicProps) { + const sessionPromise = auth(); + const initialBoardPromise = getInitialBoard(params).then( + (board) => ({ status: "fulfilled", board }) as const, + (error: unknown) => ({ status: "rejected", error }) as const, + ); + const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { + if (!withTour || !session || env.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedBoardTour; + } catch (error) { + logger.error(new Error("Failed to load the board tour status", { cause: error })); + return false; + } + }); + const [session, initialBoardResult, shouldRunBoardTour] = await Promise.all([ + sessionPromise, + initialBoardPromise, + shouldRunBoardTourPromise, + ]); + if (initialBoardResult.status === "rejected") { + const { error } = initialBoardResult; + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + if (!session) { + logger.debug("No home board found for anonymous user, redirecting to login"); + redirect("/auth/login"); + } + + logger.warn(error); + notFound(); + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + notFound(); + } + + throw error; + } + const initialBoard = initialBoardResult.board; + + return ( + + + + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + + + + + ); +} + export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, @@ -41,69 +129,18 @@ export const createBoardLayout = ({ params: Promise; }>) => { const resolvedParams = await params; - const sessionPromise = auth(); - const initialBoardPromise = getInitialBoard(resolvedParams).then( - (board) => ({ status: "fulfilled", board }) as const, - (error: unknown) => ({ status: "rejected", error }) as const, - ); - const colorSchemePromise = getCurrentColorSchemeAsync(); - const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { - if (!withTour || !session || env.DEMO_MODE) return false; - - try { - const user = await getRscUserSettingsAsync(session.user.id); - return user !== undefined && !user.completedBoardTour; - } catch (error) { - logger.error(new Error("Failed to load the board tour status", { cause: error })); - return false; - } - }); - const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([ - sessionPromise, - initialBoardPromise, - colorSchemePromise, - shouldRunBoardTourPromise, - ]); - if (initialBoardResult.status === "rejected") { - const { error } = initialBoardResult; - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (!session) { - logger.debug("No home board found for anonymous user, redirecting to login"); - redirect("/auth/login"); - } - - logger.warn(error); - notFound(); - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - notFound(); - } - - throw error; - } - const initialBoard = initialBoardResult.board; return ( - - - - - - - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - - - - - - + }> + + {children} + + ); }; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index ea62beadf..afb557ffb 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata, Viewport } from "next"; +import { Suspense } from "react"; import { Inter } from "next/font/google"; +import { ColorSchemeScript } from "@mantine/core"; import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; @@ -10,42 +12,19 @@ import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; import { notFound } from "next/navigation"; -import type { DayOfWeek } from "@mantine/dates"; -import { NextIntlClientProvider } from "next-intl"; -import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; -import { getRscUserSettingsAsync } from "@homarr/api/user-server"; -import { env } from "@homarr/auth/env"; -import { auth } from "@homarr/auth/next"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ModalProvider } from "@homarr/modals"; -import { Notifications } from "@homarr/notifications"; -import { SettingsProvider } from "@homarr/settings"; -import { SpotlightProvider } from "@homarr/spotlight"; import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; -import { Analytics } from "~/components/layout/analytics"; -import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; - import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; -import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; -import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; -import { DayJsLoader } from "./_client-providers/dayjs-loader"; -import { JotaiProvider } from "./_client-providers/jotai"; -import { CustomMantineProvider } from "./_client-providers/mantine"; -import { AuthProvider } from "./_client-providers/session"; -import { TRPCReactProvider } from "./_client-providers/trpc"; -import { composeWrappers } from "./compose"; +import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; +import { SessionScopedProviders } from "./_session-scoped-providers"; const fontSans = Inter({ subsets: ["latin"], variable: "--font-sans", }); -const logger = createLogger({ module: "rootLayout" }); - -// eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise => ({ title: "Homarr", description: @@ -65,7 +44,7 @@ export const generateMetadata = async (): Promise => ({ title: "Homarr", capable: true, startupImage: { url: "/logo/logo.png" }, - statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default", + statusBarStyle: "default", }, }); @@ -85,82 +64,19 @@ export default async function Layout(props: { notFound(); } - const sessionPromise = auth(); - const userPromise = sessionPromise.then((session) => - session - ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { - logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); - return null; - }) - : null, - ); - const [session, user, serverSettings, colorScheme] = await Promise.all([ - sessionPromise, - userPromise, - getRscServerSettingsAsync(), - getCurrentColorSchemeAsync(), - ]); const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; - const StackedProvider = composeWrappers([ - (innerProps) => { - return ; - }, - (innerProps) => ( - - ), - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - ]); - return ( - // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering - + + - - - - - {props.children} - + + {props.children} + ); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 33bdcce06..927051d13 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren } from "react"; -import { AppShellMain } from "@mantine/core"; +import { Suspense } from "react"; +import { AppShellMain, Skeleton } from "@mantine/core"; import { IconAffiliateFilled, IconApi, @@ -46,7 +47,17 @@ import { env as nextEnv } from "~/env"; const logger = createLogger({ module: "manageLayout" }); -export default async function ManageLayout({ children }: PropsWithChildren) { +const ManageLayoutFallback = () => ( + + + + + + + +); + +async function ManageLayoutContent({ children }: PropsWithChildren) { const sessionPromise = auth(); const shouldRunManageTourPromise = sessionPromise.then(async (session) => { if (!session || nextEnv.DEMO_MODE) return false; @@ -237,17 +248,21 @@ export default async function ManageLayout({ children }: PropsWithChildren) { const isAdmin = session?.user.permissions.includes("admin") ?? false; - const shell = ( - - - - {children} - - ); - return ( - {shell} + + + + {children} + ); } + +export default function ManageLayout({ children }: PropsWithChildren) { + return ( + }> + {children} + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/loading.tsx b/apps/nextjs/src/app/[locale]/manage/loading.tsx new file mode 100644 index 000000000..a6e939b92 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/loading.tsx @@ -0,0 +1,9 @@ +import { Center, Loader } from "@mantine/core"; + +export default function ManageLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 454e7a805..fc1144cab 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -11,6 +11,10 @@ interface Props { params: Promise<{ kind: string }>; } +export function generateStaticParams() { + return widgetKinds.map((kind) => ({ kind })); +} + export default async function WidgetPreview(props: Props) { const { kind } = await props.params; if (!widgetKinds.includes(kind as WidgetKind)) { diff --git a/apps/nextjs/src/app/api/user-medias/[id]/route.ts b/apps/nextjs/src/app/api/user-medias/[id]/route.ts index e3401c976..35cd3ea61 100644 --- a/apps/nextjs/src/app/api/user-medias/[id]/route.ts +++ b/apps/nextjs/src/app/api/user-medias/[id]/route.ts @@ -1,11 +1,12 @@ import { notFound } from "next/navigation"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import DOMPurify from "isomorphic-dompurify"; import { db, eq } from "@homarr/db"; import { medias } from "@homarr/db/schema"; +import { sanitizeSvg } from "../svg-purify"; + export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) { const params = await props.params; const image = await db.query.medias.findFirst({ @@ -25,9 +26,7 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri // Sanitize SVG content to prevent XSS attacks if (image.contentType === "image/svg+xml" || image.contentType === "image/svg") { const svgText = new TextDecoder().decode(content); - const sanitized = DOMPurify.sanitize(svgText, { - USE_PROFILES: { svg: true, svgFilters: true }, - }); + const sanitized = sanitizeSvg(svgText); content = new TextEncoder().encode(sanitized); } diff --git a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts new file mode 100644 index 000000000..9218bf6b1 --- /dev/null +++ b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { purify, sanitizeSvg, svgSanitizeOptions } from "./svg-purify"; + +describe("SVG sanitization", () => { + it("strips script tags from SVG", () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onload"); + }); + + it("strips data URI in use href", () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("data:"); + }); + + it("strips foreignObject", () => { + const dirty = + ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("foreignObject"); + }); + + it("preserves safe SVG content", () => { + const safe = ''; + const clean = sanitizeSvg(safe); + expect(clean).toContain("circle"); + expect(clean).toContain('fill="red"'); + }); + + it("uses the same DOMPurify options as production", () => { + const dirty = ''; + const wrapped = `${dirty}`; + expect(sanitizeSvg(dirty)).toBe(purify.sanitize(wrapped, svgSanitizeOptions)); + }); +}); diff --git a/apps/nextjs/src/app/api/user-medias/svg-purify.ts b/apps/nextjs/src/app/api/user-medias/svg-purify.ts new file mode 100644 index 000000000..d01820d65 --- /dev/null +++ b/apps/nextjs/src/app/api/user-medias/svg-purify.ts @@ -0,0 +1,90 @@ +import createDOMPurify from "dompurify"; +import { parseHTML } from "linkedom"; + +const svgSanitizeOptions = { + USE_PROFILES: { svg: true, svgFilters: true }, +} as const; + +// ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify +// resolves them via prototype getters (lookupGetter). Without this shim, sanitize +// is a no-op and XSS payloads pass through unchanged. +const patchLinkedomNodePrototype = (Node: typeof window.Node) => { + for (const prop of [ + "ownerDocument", + "parentNode", + "nextSibling", + "previousSibling", + "nodeName", + "nodeType", + "childNodes", + ] as const) { + if (Object.getOwnPropertyDescriptor(Node.prototype, prop)?.get) { + continue; + } + + if (Object.getOwnPropertyDescriptor(Node.prototype, prop)?.value !== undefined) { + delete Node.prototype[prop as keyof Node]; + } + + Object.defineProperty(Node.prototype, prop, { + configurable: true, + enumerable: true, + get() { + return Object.getOwnPropertyDescriptor(this, prop)?.value ?? null; + }, + set(value: unknown) { + Object.defineProperty(this, prop, { + value, + writable: true, + configurable: true, + enumerable: true, + }); + }, + }); + } +}; + +const createDocument = () => parseHTML(""); + +const { window, document, Node } = createDocument(); +patchLinkedomNodePrototype(Node); + +if (!document.implementation?.createHTMLDocument) { + Object.defineProperty(document, "implementation", { + value: { + createHTMLDocument() { + return createDocument().document; + }, + createDocument() { + return createDocument().document; + }, + }, + }); +} + +window.NodeFilter ??= { + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + FILTER_SKIP: 3, + SHOW_ALL: 0xffffffff, + SHOW_ELEMENT: 1, + SHOW_ATTRIBUTE: 2, + SHOW_TEXT: 4, + SHOW_CDATA_SECTION: 8, + SHOW_PROCESSING_INSTRUCTION: 64, + SHOW_COMMENT: 128, + SHOW_DOCUMENT: 0x100, + SHOW_DOCUMENT_TYPE: 0x200, + SHOW_DOCUMENT_FRAGMENT: 0x400, + SHOW_NOTATION: 0x800, +} as typeof window.NodeFilter; + +const purify = createDOMPurify(window as unknown as Parameters[0]); + +export const sanitizeSvg = (svgText: string) => { + // linkedom's DOMParser omits html/body for fragment-only SVG; DOMPurify needs a body root. + const wrapped = `${svgText}`; + return purify.sanitize(wrapped, svgSanitizeOptions); +}; + +export { svgSanitizeOptions, purify }; diff --git a/packages/api/package.json b/packages/api/package.json index 92f68ea36..1808e4c57 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,8 @@ ".": "./src/index.ts", "./client": "./src/client.ts", "./board-server": "./src/board-server.ts", + "./cache-invalidation": "./src/cache-invalidation.ts", + "./cache-tags": "./src/cache-tags.ts", "./query-cache": "./src/query-cache.ts", "./server-settings-server": "./src/server-settings-server.ts", "./user-server": "./src/user-server.ts", diff --git a/packages/api/src/cache-invalidation.ts b/packages/api/src/cache-invalidation.ts new file mode 100644 index 000000000..55b0e45fe --- /dev/null +++ b/packages/api/src/cache-invalidation.ts @@ -0,0 +1,23 @@ +import { revalidateTag } from "next/cache"; + +import { cacheTags } from "./cache-tags"; +import { invalidateIntegrationDataCache } from "./integration-data-cache"; + +export const invalidateBoardCache = (boardId: string, boardName?: string) => { + revalidateTag(cacheTags.board(boardId), "max"); + if (boardName) revalidateTag(cacheTags.boardByName(boardName), "max"); + revalidateTag(cacheTags.boardList(), "max"); +}; + +export const invalidateIntegrationCache = (integrationId: string) => { + invalidateIntegrationDataCache(integrationId); + revalidateTag(cacheTags.integration(integrationId), "max"); +}; + +export const invalidateUserCache = (userId: string) => { + revalidateTag(cacheTags.user(userId), "max"); +}; + +export const invalidateServerSettingsCache = () => { + revalidateTag(cacheTags.serverSettings(), "max"); +}; diff --git a/packages/api/src/cache-tags.spec.ts b/packages/api/src/cache-tags.spec.ts new file mode 100644 index 000000000..d98706d8e --- /dev/null +++ b/packages/api/src/cache-tags.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { cacheTags } from "./cache-tags"; + +describe("cacheTags", () => { + it("produces stable tag shapes", () => { + expect(cacheTags.serverSettings()).toBe("server-settings"); + expect(cacheTags.board("abc")).toBe("board:abc"); + expect(cacheTags.boardByName("My Board")).toBe("board-name:MY BOARD"); + expect(cacheTags.boardList()).toBe("board-list"); + expect(cacheTags.user("u1")).toBe("user:u1"); + expect(cacheTags.integration("i1")).toBe("integration:i1"); + expect(cacheTags.integrationData("i1", "downloads")).toBe("integration-data:i1:downloads"); + expect(cacheTags.app("a1")).toBe("app:a1"); + }); + + it("boardByName uppercases consistently", () => { + expect(cacheTags.boardByName("home")).toBe(cacheTags.boardByName("HOME")); + expect(cacheTags.boardByName("Home")).toBe(cacheTags.boardByName("hOmE")); + }); +}); diff --git a/packages/api/src/cache-tags.ts b/packages/api/src/cache-tags.ts new file mode 100644 index 000000000..c534e52a6 --- /dev/null +++ b/packages/api/src/cache-tags.ts @@ -0,0 +1,10 @@ +export const cacheTags = { + serverSettings: () => "server-settings" as const, + board: (boardId: string) => `board:${boardId}` as const, + boardByName: (name: string) => `board-name:${name.toUpperCase()}` as const, + boardList: () => "board-list" as const, + user: (userId: string) => `user:${userId}` as const, + integration: (integrationId: string) => `integration:${integrationId}` as const, + integrationData: (integrationId: string, query: string) => `integration-data:${integrationId}:${query}` as const, + app: (appId: string) => `app:${appId}` as const, +} as const; diff --git a/packages/api/src/integration-data-cache.spec.ts b/packages/api/src/integration-data-cache.spec.ts new file mode 100644 index 000000000..13594f0d9 --- /dev/null +++ b/packages/api/src/integration-data-cache.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { getCachedIntegrationData, invalidateIntegrationDataCache } from "./integration-data-cache"; + +afterEach(() => { + invalidateIntegrationDataCache("integration-a"); + invalidateIntegrationDataCache("integration-b"); +}); + +describe("getCachedIntegrationData", () => { + test("returns cached data within TTL without refetching", async () => { + const fetcher = vi.fn(async () => ({ value: 1 })); + + await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({ + value: 1, + }); + await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({ + value: 1, + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("serves stale data while revalidating after TTL", async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ value: 1 }) + .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve({ value: 2 }), 100))); + + await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50); + vi.advanceTimersByTime(60); + + const staleResult = await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50); + expect(staleResult).toEqual({ value: 1 }); + expect(fetcher).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(100); + await expect(getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50)).resolves.toEqual({ value: 2 }); + + vi.useRealTimers(); + }); + + test("returns stale data when refetch fails", async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ value: 1 }) + .mockRejectedValueOnce(new Error("upstream unavailable")); + + await getCachedIntegrationData("integration-a", "widget:error", fetcher, 50); + vi.advanceTimersByTime(60); + + await expect(getCachedIntegrationData("integration-a", "widget:error", fetcher, 50)).resolves.toEqual({ + value: 1, + }); + + vi.useRealTimers(); + }); + + test("invalidates all cache entries for an integration", async () => { + const fetcherOne = vi.fn(async () => ({ value: "one" })); + const fetcherTwo = vi.fn(async () => ({ value: "two" })); + const fetcherOther = vi.fn(async () => ({ value: "other" })); + + await getCachedIntegrationData("integration-a", "widget:one", fetcherOne); + await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo); + await getCachedIntegrationData("integration-b", "widget:one", fetcherOther); + + invalidateIntegrationDataCache("integration-a"); + + await getCachedIntegrationData("integration-a", "widget:one", fetcherOne); + await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo); + await getCachedIntegrationData("integration-b", "widget:one", fetcherOther); + + expect(fetcherOne).toHaveBeenCalledTimes(2); + expect(fetcherTwo).toHaveBeenCalledTimes(2); + expect(fetcherOther).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/integration-data-cache.ts b/packages/api/src/integration-data-cache.ts new file mode 100644 index 000000000..896630f7f --- /dev/null +++ b/packages/api/src/integration-data-cache.ts @@ -0,0 +1,78 @@ +// ponytail: module-level cache — one snapshot per integration per query key. +// Ceiling: stale data for up to `ttlMs` (60s default). Upgrade: Redis-backed with pub/sub invalidation. + +interface CachedEntry { + data: T; + timestamp: number; + pending?: Promise; +} + +const cache = new Map>(); +const DEFAULT_TTL_MS = 60_000; +const MAX_ENTRIES = 200; + +const cacheKey = (integrationId: string, queryKey: string) => `${integrationId}:${queryKey}`; + +export const getCachedIntegrationData = async ( + integrationId: string, + queryKey: string, + fetcher: () => Promise, + ttlMs = DEFAULT_TTL_MS, +): Promise => { + const key = cacheKey(integrationId, queryKey); + const existing = cache.get(key) as CachedEntry | undefined; + const now = Date.now(); + + if (existing) { + if (now - existing.timestamp < ttlMs) { + return existing.data; + } + if (existing.pending) { + return existing.pending; + } + } + + const pending = fetcher() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + + if (cache.size > MAX_ENTRIES) { + let oldest: string | undefined; + let oldestTime = Infinity; + for (const [k, v] of cache) { + if (v.timestamp < oldestTime) { + oldestTime = v.timestamp; + oldest = k; + } + } + if (oldest) cache.delete(oldest); + } + + return data; + }) + .catch((error) => { + const entry = cache.get(key) as CachedEntry | undefined; + if (entry) { + delete entry.pending; + if (entry.timestamp > 0) return entry.data; + } + cache.delete(key); + throw error; + }); + + if (existing) { + existing.pending = pending; + return existing.data; + } + + cache.set(key, { data: undefined as T, timestamp: 0, pending }); + return pending; +}; + +export const invalidateIntegrationDataCache = (integrationId: string) => { + for (const [key] of cache) { + if (key.startsWith(`${integrationId}:`)) { + cache.delete(key); + } + } +}; diff --git a/packages/api/src/router/app/app-access-control.ts b/packages/api/src/router/app/app-access-control.ts index 10ee934cc..86b56e21d 100644 --- a/packages/api/src/router/app/app-access-control.ts +++ b/packages/api/src/router/app/app-access-control.ts @@ -5,7 +5,7 @@ import type { Database } from "@homarr/db"; import { eq, or } from "@homarr/db"; import { items } from "@homarr/db/schema"; -import type { WidgetComponentProps } from "../../../../widgets/src"; +import type { WidgetComponentProps } from "../../../../widgets/src/definition"; export class AppAccessControl { constructor( diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 88a1c42be..2f5cabe26 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -59,6 +59,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; +import { invalidateBoardCache, invalidateUserCache } from "../cache-invalidation"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; import { generateResponsiveGridFor } from "./board/grid-algorithm"; @@ -327,6 +328,8 @@ export const boardRouter = createTRPCRouter({ await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id)); } + invalidateBoardCache(boardId, input.name); + return { boardId }; }), duplicateBoard: permissionRequiredProcedure @@ -555,6 +558,8 @@ export const boardRouter = createTRPCRouter({ }, }); + invalidateBoardCache(newBoardId, input.name); + return { boardId: newBoardId }; }), renameBoard: protectedProcedure @@ -573,7 +578,17 @@ export const boardRouter = createTRPCRouter({ await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]); + const existingBoard = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.id), + columns: { name: true }, + }); + await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id)); + + invalidateBoardCache(input.id, input.name); + if (existingBoard && existingBoard.name !== input.name) { + invalidateBoardCache(input.id, existingBoard.name); + } }), changeBoardVisibility: protectedProcedure .meta({ @@ -604,6 +619,8 @@ export const boardRouter = createTRPCRouter({ .update(boards) .set({ isPublic: input.visibility === "public" }) .where(eq(boards.id, input.id)); + + invalidateBoardCache(input.id); }), deleteBoard: protectedProcedure .meta({ @@ -619,7 +636,14 @@ export const boardRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); + const existingBoard = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.id), + columns: { name: true }, + }); + await ctx.db.delete(boards).where(eq(boards.id, input.id)); + + invalidateBoardCache(input.id, existingBoard?.name); }), setHomeBoard: protectedProcedure .meta({ @@ -636,6 +660,8 @@ export const boardRouter = createTRPCRouter({ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view"); await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id)); + + invalidateUserCache(ctx.session.user.id); }), setMobileHomeBoard: protectedProcedure .meta({ @@ -652,6 +678,8 @@ export const boardRouter = createTRPCRouter({ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view"); await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id)); + + invalidateUserCache(ctx.session.user.id); }), getHomeBoard: publicProcedure.query(async ({ ctx }) => { const userId = ctx.session?.user.id; @@ -806,6 +834,8 @@ export const boardRouter = createTRPCRouter({ if (removedLayoutIds.length > 0) { await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds)); } + + invalidateBoardCache(board.id, board.name); }), savePartialBoardSettings: protectedProcedure .meta({ @@ -850,6 +880,8 @@ export const boardRouter = createTRPCRouter({ disableStatus: input.disableStatus, }) .where(eq(boards.id, input.id)); + + invalidateBoardCache(input.id); }), saveBoard: protectedProcedure.input(boardSaveSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); @@ -1266,6 +1298,8 @@ export const boardRouter = createTRPCRouter({ }); }, }); + + invalidateBoardCache(dbBoard.id, dbBoard.name); }), getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); @@ -1342,6 +1376,11 @@ export const boardRouter = createTRPCRouter({ saveUserBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); + const existingUserPermissions = await ctx.db.query.boardUserPermissions.findMany({ + columns: { userId: true }, + where: eq(boardUserPermissions.boardId, input.entityId), + }); + await handleTransactionsAsync(ctx.db, { async handleAsync(db, schema) { await db.transaction(async (transaction) => { @@ -1377,10 +1416,25 @@ export const boardRouter = createTRPCRouter({ }); }, }); + + const userIds = [ + ...new Set([ + ...existingUserPermissions.map((permission) => permission.userId), + ...input.permissions.map((permission) => permission.principalId), + ]), + ]; + for (const userId of userIds) { + invalidateUserCache(userId); + } }), saveGroupBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); + const existingGroupPermissions = await ctx.db.query.boardGroupPermissions.findMany({ + columns: { groupId: true }, + where: eq(boardGroupPermissions.boardId, input.entityId), + }); + await handleTransactionsAsync(ctx.db, { async handleAsync(db, schema) { await db.transaction(async (transaction) => { @@ -1418,6 +1472,22 @@ export const boardRouter = createTRPCRouter({ }); }, }); + + const groupIds = [ + ...new Set([ + ...existingGroupPermissions.map((permission) => permission.groupId), + ...input.permissions.map((permission) => permission.principalId), + ]), + ]; + if (groupIds.length > 0) { + const groupMembersForBoard = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: inArray(groupMembers.groupId, groupIds), + }); + for (const member of groupMembersForBoard) { + invalidateUserCache(member.userId); + } + } }), importOldmarrConfig: permissionRequiredProcedure .requiresPermission("board-create") @@ -1554,6 +1624,8 @@ export const boardRouter = createTRPCRouter({ .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId }))); } + invalidateBoardCache(input.boardId, board.name); + return { itemId }; }), }); diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 7c00ff125..f15741456 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -19,6 +19,7 @@ import { } from "@homarr/validation/group"; import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc"; +import { invalidateUserCache } from "../cache-invalidation"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; export const groupRouter = createTRPCRouter({ @@ -245,6 +246,14 @@ export const groupRouter = createTRPCRouter({ mobileHomeBoardId: input.settings.mobileHomeBoardId, }) .where(eq(groups.id, input.id)); + + const members = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: eq(groupMembers.groupId, input.id), + }); + for (const member of members) { + invalidateUserCache(member.userId); + } }), savePositions: permissionRequiredProcedure .requiresPermission("admin") @@ -275,6 +284,11 @@ export const groupRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.groupId); + const members = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: eq(groupMembers.groupId, input.groupId), + }); + await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)); if (input.permissions.length > 0) { @@ -285,6 +299,10 @@ export const groupRouter = createTRPCRouter({ })), ); } + + for (const member of members) { + invalidateUserCache(member.userId); + } }), transferOwnership: permissionRequiredProcedure .requiresPermission("admin") @@ -307,7 +325,16 @@ export const groupRouter = createTRPCRouter({ await throwIfGroupNotFoundAsync(ctx.db, input.id); await throwIfGroupNameIsReservedAsync(ctx.db, input.id); + const members = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: eq(groupMembers.groupId, input.id), + }); + await ctx.db.delete(groups).where(eq(groups.id, input.id)); + + for (const member of members) { + invalidateUserCache(member.userId); + } }), addMember: permissionRequiredProcedure .requiresPermission("admin") @@ -339,6 +366,8 @@ export const groupRouter = createTRPCRouter({ groupId: input.groupId, userId: input.userId, }); + + invalidateUserCache(input.userId); }), removeMember: permissionRequiredProcedure .requiresPermission("admin") @@ -351,6 +380,8 @@ export const groupRouter = createTRPCRouter({ await ctx.db .delete(groupMembers) .where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId))); + + invalidateUserCache(input.userId); }), }); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index af237bb31..0de72ff7d 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -37,6 +37,7 @@ import { import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationCache, invalidateUserCache } from "../../cache-invalidation"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "./integration-access"; import { MissingSecretError, testConnectionAsync } from "./integration-test-connection"; @@ -368,6 +369,8 @@ export const integrationRouter = createTRPCRouter({ kind: input.kind, url: input.url, }); + + invalidateIntegrationCache(integrationId); }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -474,6 +477,7 @@ export const integrationRouter = createTRPCRouter({ // Invalidate all cached data for this integration so that widgets pick up the // new configuration immediately instead of serving stale (or errored) data. await invalidateIntegrationCacheAsync(input.id); + invalidateIntegrationCache(input.id); }), delete: protectedProcedure .meta({ @@ -498,6 +502,7 @@ export const integrationRouter = createTRPCRouter({ // Clean up any cached data left behind by the deleted integration. await invalidateIntegrationCacheAsync(input.id); + invalidateIntegrationCache(input.id); }), getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -576,6 +581,11 @@ export const integrationRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full"); + const existingUserPermissions = await ctx.db.query.integrationUserPermissions.findMany({ + columns: { userId: true }, + where: eq(integrationUserPermissions.integrationId, input.entityId), + }); + await handleTransactionsAsync(ctx.db, { async handleAsync(db, schema) { await ctx.db.transaction(async (transaction) => { @@ -616,12 +626,28 @@ export const integrationRouter = createTRPCRouter({ }); }, }); + + const userIds = [ + ...new Set([ + ...existingUserPermissions.map((permission) => permission.userId), + ...input.permissions.map((permission) => permission.principalId), + ]), + ]; + for (const userId of userIds) { + invalidateUserCache(userId); + } + invalidateIntegrationCache(input.entityId); }), saveGroupIntegrationPermissions: protectedProcedure .input(integrationSavePermissionsSchema) .mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full"); + const existingGroupPermissions = await ctx.db.query.integrationGroupPermissions.findMany({ + columns: { groupId: true }, + where: eq(integrationGroupPermissions.integrationId, input.entityId), + }); + await handleTransactionsAsync(ctx.db, { async handleAsync(db, schema) { await db.transaction(async (transaction) => { @@ -662,6 +688,23 @@ export const integrationRouter = createTRPCRouter({ }); }, }); + + const groupIds = [ + ...new Set([ + ...existingGroupPermissions.map((permission) => permission.groupId), + ...input.permissions.map((permission) => permission.principalId), + ]), + ]; + if (groupIds.length > 0) { + const members = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: inArray(groupMembers.groupId, groupIds), + }); + for (const member of members) { + invalidateUserCache(member.userId); + } + } + invalidateIntegrationCache(input.entityId); }), searchInIntegration: protectedProcedure .meta({ diff --git a/packages/api/src/router/section/section-router.ts b/packages/api/src/router/section/section-router.ts index 81613f581..8a440a80c 100644 --- a/packages/api/src/router/section/section-router.ts +++ b/packages/api/src/router/section/section-router.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { and, eq } from "@homarr/db"; import { sectionCollapseStates, sections } from "@homarr/db/schema"; +import { invalidateUserCache } from "../../cache-invalidation"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; export const sectionRouter = createTRPCRouter({ @@ -37,6 +38,7 @@ export const sectionRouter = createTRPCRouter({ userId: ctx.session.user.id, collapsed: input.collapsed, }); + invalidateUserCache(ctx.session.user.id); return; } @@ -48,5 +50,7 @@ export const sectionRouter = createTRPCRouter({ .where( and(eq(sectionCollapseStates.sectionId, section.id), eq(sectionCollapseStates.userId, ctx.session.user.id)), ); + + invalidateUserCache(ctx.session.user.id); }), }); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 9783e0ae9..308926475 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -14,6 +14,7 @@ import { defaultServerSettingsKeys } from "@homarr/server-settings"; import { settingsInitSchema } from "@homarr/validation/settings"; import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc"; +import { invalidateServerSettingsCache } from "../cache-invalidation"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; const boardServerSettingsSchema = z.object({ @@ -91,6 +92,8 @@ export const serverSettingsRouter = createTRPCRouter({ await insertServerSettingByKeyAsync(ctx.db, "board", next); } + invalidateServerSettingsCache(); + return next; }), saveSettings: permissionRequiredProcedure @@ -107,6 +110,8 @@ export const serverSettingsRouter = createTRPCRouter({ ...current, ...input.value, } as ServerSettings[keyof ServerSettings]); + + invalidateServerSettingsCache(); }), initSettings: onboardingProcedure .requiresStep("settings") @@ -116,5 +121,7 @@ export const serverSettingsRouter = createTRPCRouter({ await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics }); await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing); await nextOnboardingStepAsync(ctx.db, undefined); + + invalidateServerSettingsCache(); }), }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index a7e1735ee..a93cd1711 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -31,6 +31,7 @@ import { } from "@homarr/validation/user"; import { convertIntersectionToZodObject } from "../schema-merger"; +import { invalidateUserCache } from "../cache-invalidation"; import { createTRPCRouter, isDemoMode, @@ -221,6 +222,8 @@ export const userRouter = createTRPCRouter({ image: input.image, }) .where(eq(users.id, input.userId)); + + invalidateUserCache(input.userId); }), getAll: permissionRequiredProcedure .requiresPermission("admin") @@ -452,6 +455,8 @@ export const userRouter = createTRPCRouter({ emailVerified: emailDirty === true ? null : undefined, }) .where(eq(users.id, input.id)); + + invalidateUserCache(input.id); }), delete: protectedProcedure .input(z.object({ userId: z.string() })) @@ -598,6 +603,8 @@ export const userRouter = createTRPCRouter({ mobileHomeBoardId: input.mobileHomeBoardId, }) .where(eq(users.id, input.userId)); + + invalidateUserCache(input.userId); }), changeDefaultSearchEngine: protectedProcedure .input( @@ -653,6 +660,8 @@ export const userRouter = createTRPCRouter({ colorScheme: input.colorScheme, }) .where(eq(users.id, ctx.session.user.id)); + + invalidateUserCache(ctx.session.user.id); }), changeEnableRightClickOnWidgets: protectedProcedure .meta({ @@ -679,6 +688,8 @@ export const userRouter = createTRPCRouter({ enableRightClickOnWidgets: input.enableRightClickOnWidgets, }) .where(eq(users.id, input.id)); + + invalidateUserCache(input.id); }), changePingIconsEnabled: protectedProcedure .input(userPingIconsEnabledSchema.and(byIdSchema)) @@ -697,6 +708,8 @@ export const userRouter = createTRPCRouter({ pingIconsEnabled: input.pingIconsEnabled, }) .where(eq(users.id, input.id)); + + invalidateUserCache(input.id); }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) @@ -724,6 +737,8 @@ export const userRouter = createTRPCRouter({ ddgBangs: input.ddgBangs, }) .where(eq(users.id, input.id)); + + invalidateUserCache(input.id); }), changeFirstDayOfWeek: protectedProcedure .input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema))) @@ -765,6 +780,8 @@ export const userRouter = createTRPCRouter({ firstDayOfWeek: input.firstDayOfWeek, }) .where(eq(users.id, input.id)); + + invalidateUserCache(input.id); }), completeTour: protectedProcedure .input(z.object({ tour: z.enum(["manage", "board"]) })) @@ -775,6 +792,8 @@ export const userRouter = createTRPCRouter({ } as const; await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id)); + + invalidateUserCache(ctx.session.user.id); }), resetTours: protectedProcedure.mutation(async ({ ctx }) => { await ctx.db @@ -784,6 +803,8 @@ export const userRouter = createTRPCRouter({ completedBoardTour: false, }) .where(eq(users.id, ctx.session.user.id)); + + invalidateUserCache(ctx.session.user.id); }), getTourStatus: protectedProcedure.query(async ({ ctx }) => { if (isDemoMode) { diff --git a/packages/api/src/router/user/change-search-preferences.ts b/packages/api/src/router/user/change-search-preferences.ts index 41b69bfa4..e4c53eab9 100644 --- a/packages/api/src/router/user/change-search-preferences.ts +++ b/packages/api/src/router/user/change-search-preferences.ts @@ -8,6 +8,8 @@ import type { Database } from "@homarr/db"; import { users } from "@homarr/db/schema"; import { userChangeSearchPreferencesSchema } from "@homarr/validation/user"; +import { invalidateUserCache } from "../../cache-invalidation"; + export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSchema.and( z.object({ userId: z.string() }), ); @@ -51,4 +53,6 @@ export const changeSearchPreferencesAsync = async ( ddgBangs: input.ddgBangsEnabled, }) .where(eq(users.id, input.userId)); + + invalidateUserCache(input.userId); }; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 738adecf7..c8760931c 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -10,7 +10,7 @@ import { beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -47,6 +47,7 @@ export const beszelRouter = createTRPCRouter({ }; }, { + queryKey: integrationQueryKey("beszel", "getSystems"), fallback: (integration, error) => ({ integrationId: integration.id, integrationName: integration.name, @@ -119,6 +120,10 @@ export const beszelRouter = createTRPCRouter({ }; }, { + queryKey: integrationQueryKey("beszel", "getAlerts", { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }), fallback: (integration, error) => ({ integrationId: integration.id, integrationName: integration.name, diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts index 9476039d9..40986e37a 100644 --- a/packages/api/src/router/widgets/coolify.ts +++ b/packages/api/src/router/widgets/coolify.ts @@ -1,14 +1,16 @@ import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const coolifyRouter = createTRPCRouter({ getInstancesInfo: publicProcedure .concat(createManyIntegrationMiddleware("query", "coolify")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = coolifyRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -19,6 +21,8 @@ export const coolifyRouter = createTRPCRouter({ instanceInfo: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("coolify", "getInstancesInfo") }, + ); }), }); diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 32f15da98..0318f1b1a 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -5,7 +5,7 @@ import { createIntegrationAsync } from "@homarr/integrations"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ @@ -19,13 +19,17 @@ export const dnsHoleRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, summary: data, }; - }); + }, + { queryKey: integrationQueryKey("dns-hole", "summary") }, + ); }), enable: protectedProcedure diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index bdce2473f..c4de1e424 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -6,7 +6,7 @@ import { downloadClientRequestHandler } from "@homarr/request-handler/downloads" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => @@ -24,14 +24,18 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); const { data, timestamp } = await innerHandler.getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, data, }; - }); + }, + { queryKey: integrationQueryKey("downloads", "getJobsAndStatuses", { limitPerIntegration: input.limitPerIntegration }) }, + ); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 08951da7c..336b6d31e 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -7,7 +7,7 @@ import { } from "@homarr/request-handler/firewall"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")); @@ -18,20 +18,34 @@ const queryFirewall = < }, >( handler: THandler, + queryKey: string, ) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => - settleIntegrationQueries(ctx.integrations, async (integration) => { + settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, summary: data as Awaited["getDataAsync"]>>["data"], }; - }), + }, + { queryKey }, + ), ); export const firewallRouter = createTRPCRouter({ - getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), - getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), - getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), - getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), + getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, integrationQueryKey("firewall", "getFirewallCpuStatus")), + getFirewallInterfacesStatus: queryFirewall( + firewallInterfacesRequestHandler, + integrationQueryKey("firewall", "getFirewallInterfacesStatus"), + ), + getFirewallVersionStatus: queryFirewall( + firewallVersionRequestHandler, + integrationQueryKey("firewall", "getFirewallVersionStatus"), + ), + getFirewallMemoryStatus: queryFirewall( + firewallMemoryRequestHandler, + integrationQueryKey("firewall", "getFirewallMemoryStatus"), + ), }); diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 1957f459c..595f7fdac 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -3,7 +3,7 @@ import { SynologyIntegration } from "@homarr/integrations"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const healthMonitoringIntegrationKinds = [ @@ -27,7 +27,9 @@ export const healthMonitoringRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); return { integrationId: integration.id, @@ -35,7 +37,9 @@ export const healthMonitoringRouter = createTRPCRouter({ healthInfo: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("health-monitoring", "getSystemHealthStatus") }, + ); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 54a949dab..53a6a20d0 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => @@ -16,7 +16,9 @@ export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure .concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = indexerManagerRequestHandler.handler(integration, {}); const { data: indexers } = await innerHandler.getDataAsync(); @@ -24,7 +26,9 @@ export const indexerManagerRouter = createTRPCRouter({ integrationId: integration.id, indexers, }; - }); + }, + { queryKey: integrationQueryKey("indexer-manager", "getIndexersStatus") }, + ); }), testAllIndexers: protectedProcedure .concat(createIndexerManagerIntegrationMiddleware("interact")) diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index f05a46142..a53ad875b 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,14 +2,16 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -22,7 +24,9 @@ export const mediaReleaseRouter = createTRPCRouter({ }, releases: data, }; - }); + }, + { queryKey: integrationQueryKey("media-release", "getMediaReleases") }, + ); return results.flatMap((result) => result.releases.map((release) => ({ ...release, diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index fbd192499..f2c85d477 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -10,7 +10,7 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; export const mediaRequestsRouter = createTRPCRouter({ @@ -25,10 +25,14 @@ export const mediaRequestsRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); + }, + { queryKey: integrationQueryKey("media-requests", "getLatestRequests", input) }, + ); return results .flatMap(({ data, integration }) => data.map((request) => ({ @@ -57,10 +61,14 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); + }, + { queryKey: integrationQueryKey("media-requests", "getStats") }, + ); return { stats: results.flatMap((result) => result.data.stats), users: results diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 591d58d2b..cee1ca430 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => @@ -23,11 +23,15 @@ export const mediaServerRouter = createTRPCRouter({ .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data } = await mediaServerRequestHandler .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) .getDataAsync(); return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; - }); + }, + { queryKey: integrationQueryKey("media-server", "getCurrentStreams", { showOnlyPlaying: input.showOnlyPlaying }) }, + ); }), }); diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts index 61257d1ec..1a6cf2b73 100644 --- a/packages/api/src/router/widgets/network-controller.ts +++ b/packages/api/src/router/widgets/network-controller.ts @@ -2,14 +2,16 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = networkControllerRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -22,6 +24,8 @@ export const networkControllerRouter = createTRPCRouter({ summary: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("network-controller", "summary") }, + ); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index bae8ba9fd..4845acb01 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => @@ -13,7 +13,9 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = notificationsRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -26,6 +28,8 @@ export const notificationsRouter = createTRPCRouter({ }, data, }; - }); + }, + { queryKey: integrationQueryKey("notifications", "getNotifications") }, + ); }), }); diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index e94ee31a4..8dc5a0653 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -6,7 +6,7 @@ import { eq } from "@homarr/db"; import { getServerSettingsAsync } from "@homarr/db/queries"; import { boards, items } from "@homarr/db/schema"; -import type { WidgetOptionsSettings } from "../../../../widgets/src"; +import type { WidgetOptionsSettings } from "../../../../widgets/src/definition"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 6cb198395..0591bb78e 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -9,7 +9,7 @@ import { items, users } from "@homarr/db/schema"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; -import type { WidgetComponentProps } from "../../../../widgets/src"; +import type { WidgetComponentProps } from "../../../../widgets/src/definition"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "rssFeed" }); diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index 01e7fb58b..9e1ef3f6b 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -1,14 +1,16 @@ import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const speedtestTrackerRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "speedtestTracker")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -19,6 +21,8 @@ export const speedtestTrackerRouter = createTRPCRouter({ dashboard: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("speedtest-tracker", "getDashboard") }, + ); }), }); diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index 5075c990b..f550e778e 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -1,12 +1,14 @@ import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const tracearrRouter = createTRPCRouter({ getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = tracearrRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -17,6 +19,8 @@ export const tracearrRouter = createTRPCRouter({ dashboard: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("tracearr", "getDashboard") }, + ); }), }); diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts index 8f9c775e2..153b0c4c7 100644 --- a/packages/api/src/router/widgets/traefik.ts +++ b/packages/api/src/router/widgets/traefik.ts @@ -1,7 +1,7 @@ import { traefikRequestHandler } from "@homarr/request-handler/traefik"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const traefikRouter = createTRPCRouter({ @@ -15,7 +15,9 @@ export const traefikRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", "traefik")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = traefikRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -26,6 +28,8 @@ export const traefikRouter = createTRPCRouter({ dashboard: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("traefik", "getDashboard") }, + ); }), }); diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts index 3994f398e..935ecfd88 100644 --- a/packages/api/src/router/widgets/umami.ts +++ b/packages/api/src/router/widgets/umami.ts @@ -13,7 +13,7 @@ import { } from "@homarr/request-handler/umami"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "umami-router" }); @@ -39,7 +39,9 @@ export const umamiRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = umamiRequestHandler.handler(integration, { websiteId: input.websiteId, timeFrame: input.timeFrame, @@ -54,7 +56,15 @@ export const umamiRouter = createTRPCRouter({ visitorStats: data, updatedAt: timestamp, }; - }); + }, + { + queryKey: integrationQueryKey("umami", "getVisitorStats", { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + eventName: input.eventName, + }), + }, + ); }), getEventNames: publicProcedure diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index 16c6312dd..0b51af3d2 100644 --- a/packages/api/src/router/widgets/ups.ts +++ b/packages/api/src/router/widgets/ups.ts @@ -2,14 +2,16 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const upsRouter = createTRPCRouter({ getSummaries: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = upsSummariesRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -20,6 +22,8 @@ export const upsRouter = createTRPCRouter({ summaries: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("ups", "getSummaries") }, + ); }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 6df14090c..6a81bf719 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,14 +1,16 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); @@ -19,6 +21,8 @@ export const uptimeKumaRouter = createTRPCRouter({ dashboard: data, updatedAt: timestamp, }; - }); + }, + { queryKey: integrationQueryKey("uptime-kuma", "getDashboard") }, + ); }), }); diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 407e4986f..ab432a047 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -2,14 +2,16 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); return { integration: { @@ -20,6 +22,8 @@ export const vpnRouter = createTRPCRouter({ }, summary: data, }; - }); + }, + { queryKey: integrationQueryKey("vpn", "getSummaries") }, + ); }), }); diff --git a/packages/api/src/server-settings-server.ts b/packages/api/src/server-settings-server.ts index 29755dfac..c2f887f8a 100644 --- a/packages/api/src/server-settings-server.ts +++ b/packages/api/src/server-settings-server.ts @@ -1,8 +1,13 @@ +import "server-only"; + import { cache } from "react"; import { db } from "@homarr/db"; import { getServerSettingsAsync } from "@homarr/db/queries"; -// One settings snapshot per RSC request, shared by the root layout, theme, and -// widget prefetching. React invalidates this cache between requests. -export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db)); +// ponytail: React cache() dedupes within a single request render. +// The `use cache` layer (when enabled) dedupes across requests. +// Keep both: inner avoids repeated deserialization of cached payload per render. +export const getRscServerSettingsAsync = cache(async () => { + return await getServerSettingsAsync(db); +}); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index a9dbff5ba..31cca42c3 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -1,6 +1,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { getCachedIntegrationData } from "./integration-data-cache"; + const logger = createLogger({ module: "settleIntegrations" }); interface IntegrationLike { @@ -11,14 +13,29 @@ interface IntegrationLike { interface Options { fallback?: (integration: TIntegration, error: unknown) => TResult; + /** Widget procedure key, e.g. `downloads:getJobsAndStatuses:{"limitPerIntegration":50}` */ + queryKey?: string; } +export const integrationQueryKey = (widget: string, procedure: string, params?: unknown) => { + if (params === undefined) return `${widget}:${procedure}`; + return `${widget}:${procedure}:${JSON.stringify(params)}`; +}; + export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, options?: Options, ): Promise { - const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); + const queryKey = options?.queryKey; + const runQuery = async (integration: TIntegration) => { + if (queryKey) { + return getCachedIntegrationData(integration.id, queryKey, () => fn(integration)); + } + return fn(integration); + }; + + const settled = await Promise.allSettled(integrations.map(runQuery)); const results: TResult[] = []; const errors: unknown[] = []; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5f82ea6a3..802a31595 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,4 +11,5 @@ export * from "./number"; export * from "./error"; export * from "./theme"; export * from "./function"; +export * from "./retryable-loader"; export * from "./id"; diff --git a/packages/common/src/retryable-loader.ts b/packages/common/src/retryable-loader.ts new file mode 100644 index 000000000..46163e1e5 --- /dev/null +++ b/packages/common/src/retryable-loader.ts @@ -0,0 +1,18 @@ +export const createRetryableLoader = (loaders: ReadonlyMap Promise>) => { + const promises = new Map>(); + + return (key: TKey) => { + const existing = promises.get(key); + if (existing) return existing; + + const loader = loaders.get(key); + if (!loader) throw new Error(`No loader is registered for ${String(key)}`); + + const promise = loader(); + promises.set(key, promise); + void promise.catch(() => { + if (promises.get(key) === promise) promises.delete(key); + }); + return promise; + }; +}; diff --git a/packages/core/src/infrastructure/db/drivers/index.ts b/packages/core/src/infrastructure/db/drivers/index.ts index 0c5449c7e..11ab259c5 100644 --- a/packages/core/src/infrastructure/db/drivers/index.ts +++ b/packages/core/src/infrastructure/db/drivers/index.ts @@ -1,12 +1,15 @@ +import { createRequire } from "node:module"; + import { DB_CASING } from "../constants"; -import { createDbMapping } from "../mapping"; -import { createMysqlDb } from "./mysql"; -import { createPostgresDb } from "./postgresql"; +import { dbEnv } from "../env"; import type { SharedDrizzleConfig } from "./shared"; import { WinstonDrizzleLogger } from "./shared"; -import { createSqliteDb } from "./sqlite"; -export type Database> = ReturnType>; +const require = createRequire(import.meta.url); + +export type Database> = ReturnType< + typeof import("./sqlite").createSqliteDb +>; export const createSharedConfig = >( schema: TSchema, @@ -18,10 +21,15 @@ export const createSharedConfig = >( export const createDb = >(schema: TSchema) => { const config = createSharedConfig(schema); - - return createDbMapping({ - mysql2: () => createMysqlDb(config), - "node-postgres": () => createPostgresDb(config), - "better-sqlite3": () => createSqliteDb(config), - }); + // ponytail: conditional require — only the configured driver is loaded. + // Ceiling: all 3 stay on disk for runtime driver switching; only loaded graph shrinks. + const driver = dbEnv.DRIVER ?? "better-sqlite3"; + switch (driver) { + case "mysql2": + return (require("./mysql") as typeof import("./mysql")).createMysqlDb(config); + case "node-postgres": + return (require("./postgresql") as typeof import("./postgresql")).createPostgresDb(config); + default: + return (require("./sqlite") as typeof import("./sqlite")).createSqliteDb(config); + } }; diff --git a/packages/db/queries/item.ts b/packages/db/queries/item.ts index a24c5b2b8..0830e50a3 100644 --- a/packages/db/queries/item.ts +++ b/packages/db/queries/item.ts @@ -2,7 +2,7 @@ import type { WidgetKind } from "@homarr/definitions"; import type { Database } from ".."; import { inArray } from ".."; -import type { inferSupportedIntegrations } from "../../widgets/src"; +import type { inferSupportedIntegrations } from "../../widgets/src/import"; import { items } from "../schema"; export const getItemsWithIntegrationsAsync = async ( diff --git a/packages/integrations/src/base/creator.spec.ts b/packages/integrations/src/base/creator.spec.ts new file mode 100644 index 000000000..b0bb49372 --- /dev/null +++ b/packages/integrations/src/base/creator.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { integrationKinds } from "@homarr/definitions"; + +import { integrationLoaderKinds } from "./creator"; + +describe("integration creator", () => { + it("should have a loader for every IntegrationKind", () => { + expect(new Set(integrationLoaderKinds)).toEqual(new Set(integrationKinds)); + }); +}); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 8a9dc8b5f..27a394b48 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,144 +1,101 @@ import type { IntegrationKind } from "@homarr/definitions"; -import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; -import { BeszelIntegration } from "../beszel/beszel-integration"; -import { BazarrIntegration } from "../bazarr/bazarr-integration"; -import { AnchorIntegration } from "../anchor/anchor-integration"; -import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; -import { CoolifyIntegration } from "../coolify/coolify-integration"; -import { DashDotIntegration } from "../dashdot/dashdot-integration"; -import { Aria2Integration } from "../download-client/aria2/aria2-integration"; -import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; -import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; -import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; -import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; -import { SlskdIntegration } from "../download-client/slskd/slskd-integration"; -import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; -import { EmbyIntegration } from "../emby/emby-integration"; -import { GlancesIntegration } from "../glances/glances-integration"; -import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration"; -import { GluetunIntegration } from "../gluetun/gluetun-integration"; -import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; -import { ICalIntegration } from "../ical/ical-integration"; -import { ImmichIntegration } from "../immich/immich-integration"; -import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; -import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; -import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; -import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; -import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; -import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; -import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; -import { MockIntegration } from "../mock/mock-integration"; -import { NavidromeIntegration } from "../navidrome/navidrome-integration"; -import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; -import { GotifyIntegration } from "../gotify/gotify-integration"; -import { NTFYIntegration } from "../ntfy/ntfy-integration"; -import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; -import { OPNsenseIntegration } from "../opnsense/opnsense-integration"; -import { OverseerrIntegration } from "../overseerr/overseerr-integration"; -import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; -import { PatchMonIntegration } from "../patchmon/patchmon-integration"; -import { PeaNutIntegration } from "../peanut/peanut-integration"; -import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; -import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory"; -import { PlexIntegration } from "../plex/plex-integration"; -import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; -import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; -import { SeerrIntegration } from "../seerr/seerr-integration"; -import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; -import { TracearrIntegration } from "../tracearr/tracearr-integration"; -import { TraefikIntegration } from "../traefik/traefik-integration"; -import { SynologyIntegration } from "../synology/synology-integration"; -import { TrueNasIntegration } from "../truenas/truenas-integration"; -import { UmamiIntegration } from "../umami/umami-integration"; -import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; -import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; -import { UnraidIntegration } from "../unraid/unraid-integration"; -import type { Integration, IntegrationInput } from "./integration"; +import { createRetryableLoader } from "@homarr/common"; -export const createIntegrationAsync = async ( +import type { Integration, IntegrationInput } from "./integration"; +import type { IntegrationInstanceOfKind } from "./kind-map"; + +type IntegrationCtor = new (integration: IntegrationInput) => Integration; +type IntegrationFactory = (integration: IntegrationInput) => Promise; +type IntegrationLoader = () => Promise; + +const integrationLoaders: Record = { + anchor: () => import("../anchor/anchor-integration").then((m) => m.AnchorIntegration), + adGuardHome: () => import("../adguard-home/adguard-home-integration").then((m) => m.AdGuardHomeIntegration), + archiveTeamWarrior: () => + import("../archive-team-warrior/archive-team-warrior-integration").then((m) => m.ArchiveTeamWarriorIntegration), + aria2: () => import("../download-client/aria2/aria2-integration").then((m) => m.Aria2Integration), + audiobookshelf: () => import("../audiobookshelf/audiobookshelf-integration").then((m) => m.AudiobookshelfIntegration), + bazarr: () => import("../bazarr/bazarr-integration").then((m) => m.BazarrIntegration), + beszel: () => import("../beszel/beszel-integration").then((m) => m.BeszelIntegration), + coolify: () => import("../coolify/coolify-integration").then((m) => m.CoolifyIntegration), + dashDot: () => import("../dashdot/dashdot-integration").then((m) => m.DashDotIntegration), + deluge: () => import("../download-client/deluge/deluge-integration").then((m) => m.DelugeIntegration), + emby: () => import("../emby/emby-integration").then((m) => m.EmbyIntegration), + glances: () => import("../glances/glances-integration").then((m) => m.GlancesIntegration), + gluetun: () => import("../gluetun/gluetun-integration").then((m) => m.GluetunIntegration), + gotify: () => import("../gotify/gotify-integration").then((m) => m.GotifyIntegration), + homeAssistant: () => import("../homeassistant/homeassistant-integration").then((m) => m.HomeAssistantIntegration), + ical: () => import("../ical/ical-integration").then((m) => m.ICalIntegration), + immich: () => import("../immich/immich-integration").then((m) => m.ImmichIntegration), + jellyfin: () => import("../jellyfin/jellyfin-integration").then((m) => m.JellyfinIntegration), + jellyseerr: () => import("../jellyseerr/jellyseerr-integration").then((m) => m.JellyseerrIntegration), + lidarr: () => import("../media-organizer/lidarr/lidarr-integration").then((m) => m.LidarrIntegration), + mock: () => import("../mock/mock-integration").then((m) => m.MockIntegration), + navidrome: () => import("../navidrome/navidrome-integration").then((m) => m.NavidromeIntegration), + nextcloud: () => import("../nextcloud/nextcloud.integration").then((m) => m.NextcloudIntegration), + ntfy: () => import("../ntfy/ntfy-integration").then((m) => m.NTFYIntegration), + nzbGet: () => import("../download-client/nzbget/nzbget-integration").then((m) => m.NzbGetIntegration), + openmediavault: () => + import("../openmediavault/openmediavault-integration").then((m) => m.OpenMediaVaultIntegration), + opnsense: () => import("../opnsense/opnsense-integration").then((m) => m.OPNsenseIntegration), + overseerr: () => import("../overseerr/overseerr-integration").then((m) => m.OverseerrIntegration), + paperlessNgx: () => import("../paperless-ngx/paperless-ngx-integration").then((m) => m.PaperlessNgxIntegration), + patchmon: () => import("../patchmon/patchmon-integration").then((m) => m.PatchMonIntegration), + peaNut: () => import("../peanut/peanut-integration").then((m) => m.PeaNutIntegration), + piHole: () => + import("../pi-hole/pi-hole-integration-factory").then((m) => [m.createPiHoleIntegrationAsync] as [IntegrationFactory]), + plex: () => import("../plex/plex-integration").then((m) => m.PlexIntegration), + prowlarr: () => import("../prowlarr/prowlarr-integration").then((m) => m.ProwlarrIntegration), + proxmox: () => import("../proxmox/proxmox-integration").then((m) => m.ProxmoxIntegration), + qBittorrent: () => + import("../download-client/qbittorrent/qbittorrent-integration").then((m) => m.QBitTorrentIntegration), + radarr: () => import("../media-organizer/radarr/radarr-integration").then((m) => m.RadarrIntegration), + readarr: () => import("../media-organizer/readarr/readarr-integration").then((m) => m.ReadarrIntegration), + sabNzbd: () => import("../download-client/sabnzbd/sabnzbd-integration").then((m) => m.SabnzbdIntegration), + seerr: () => import("../seerr/seerr-integration").then((m) => m.SeerrIntegration), + slskd: () => import("../download-client/slskd/slskd-integration").then((m) => m.SlskdIntegration), + sonarr: () => import("../media-organizer/sonarr/sonarr-integration").then((m) => m.SonarrIntegration), + speedtestTracker: () => + import("../speedtest-tracker/speedtest-tracker-integration").then((m) => m.SpeedtestTrackerIntegration), + synology: () => import("../synology/synology-integration").then((m) => m.SynologyIntegration), + tdarr: () => import("../media-transcoding/tdarr-integration").then((m) => m.TdarrIntegration), + technitiumDns: () => + import("../technitium/technitium-integration-factory").then( + (m) => [m.createTechnitiumDnsIntegrationAsync] as [IntegrationFactory], + ), + tracearr: () => import("../tracearr/tracearr-integration").then((m) => m.TracearrIntegration), + traefik: () => import("../traefik/traefik-integration").then((m) => m.TraefikIntegration), + transmission: () => + import("../download-client/transmission/transmission-integration").then((m) => m.TransmissionIntegration), + truenas: () => import("../truenas/truenas-integration").then((m) => m.TrueNasIntegration), + umami: () => import("../umami/umami-integration").then((m) => m.UmamiIntegration), + unifiController: () => + import("../unifi-controller/unifi-controller-integration").then((m) => m.UnifiControllerIntegration), + unraid: () => import("../unraid/unraid-integration").then((m) => m.UnraidIntegration), + uptimeKuma: () => import("../uptime-kuma/uptime-kuma-integration").then((m) => m.UptimeKumaIntegration), +}; + +export const integrationLoaderKinds = Object.keys(integrationLoaders) as IntegrationKind[]; + +const loadIntegrationCreator = createRetryableLoader( + new Map(Object.entries(integrationLoaders) as [IntegrationKind, IntegrationLoader][]), +); + +export const createIntegrationAsync = async ( integration: IntegrationInput & { kind: TKind }, -) => { - if (!(integration.kind in integrationCreators)) { +): Promise> => { + if (!(integration.kind in integrationLoaders)) { throw new Error( `Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`, ); } - const creator = integrationCreators[integration.kind]; + const creator = await loadIntegrationCreator(integration.kind); - // factories are an array, to differentiate in js between class constructors and functions if (Array.isArray(creator)) { return (await creator[0](integration)) as IntegrationInstanceOfKind; } return new creator(integration) as IntegrationInstanceOfKind; }; - -type IntegrationInstance = new (integration: IntegrationInput) => Integration; - -// factories are an array, to differentiate in js between class constructors and functions -export const integrationCreators = { - anchor: AnchorIntegration, - piHole: [createPiHoleIntegrationAsync], - adGuardHome: AdGuardHomeIntegration, - technitiumDns: [createTechnitiumDnsIntegrationAsync], - homeAssistant: HomeAssistantIntegration, - jellyfin: JellyfinIntegration, - plex: PlexIntegration, - sonarr: SonarrIntegration, - radarr: RadarrIntegration, - sabNzbd: SabnzbdIntegration, - nzbGet: NzbGetIntegration, - qBittorrent: QBitTorrentIntegration, - deluge: DelugeIntegration, - transmission: TransmissionIntegration, - slskd: SlskdIntegration, - aria2: Aria2Integration, - jellyseerr: JellyseerrIntegration, - seerr: SeerrIntegration, - overseerr: OverseerrIntegration, - prowlarr: ProwlarrIntegration, - openmediavault: OpenMediaVaultIntegration, - lidarr: LidarrIntegration, - readarr: ReadarrIntegration, - dashDot: DashDotIntegration, - tdarr: TdarrIntegration, - proxmox: ProxmoxIntegration, - emby: EmbyIntegration, - nextcloud: NextcloudIntegration, - unifiController: UnifiControllerIntegration, - opnsense: OPNsenseIntegration, - ical: ICalIntegration, - ntfy: NTFYIntegration, - gotify: GotifyIntegration, - mock: MockIntegration, - truenas: TrueNasIntegration, - synology: SynologyIntegration, - unraid: UnraidIntegration, - coolify: CoolifyIntegration, - tracearr: TracearrIntegration, - glances: GlancesIntegration, - immich: ImmichIntegration, - paperlessNgx: PaperlessNgxIntegration, - patchmon: PatchMonIntegration, - speedtestTracker: SpeedtestTrackerIntegration, - audiobookshelf: AudiobookshelfIntegration, - navidrome: NavidromeIntegration, - umami: UmamiIntegration, - gluetun: GluetunIntegration, - archiveTeamWarrior: ArchiveTeamWarriorIntegration, - uptimeKuma: UptimeKumaIntegration, - peaNut: PeaNutIntegration, - beszel: BeszelIntegration, - bazarr: BazarrIntegration, - traefik: TraefikIntegration, -} satisfies Record Promise]>; - -type IntegrationInstanceOfKind = { - [kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise] - ? Awaited> - : (typeof integrationCreators)[kind] extends IntegrationInstance - ? InstanceType<(typeof integrationCreators)[kind]> - : never; -}[TKind]; diff --git a/packages/integrations/src/base/kind-map.ts b/packages/integrations/src/base/kind-map.ts new file mode 100644 index 000000000..1dd22e0ba --- /dev/null +++ b/packages/integrations/src/base/kind-map.ts @@ -0,0 +1,119 @@ +import type { IntegrationKind } from "@homarr/definitions"; + +// Type-only imports — fully erased at build, zero runtime modules loaded. +import type { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; +import type { AnchorIntegration } from "../anchor/anchor-integration"; +import type { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration"; +import type { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; +import type { BazarrIntegration } from "../bazarr/bazarr-integration"; +import type { BeszelIntegration } from "../beszel/beszel-integration"; +import type { CoolifyIntegration } from "../coolify/coolify-integration"; +import type { DashDotIntegration } from "../dashdot/dashdot-integration"; +import type { Aria2Integration } from "../download-client/aria2/aria2-integration"; +import type { DelugeIntegration } from "../download-client/deluge/deluge-integration"; +import type { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; +import type { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; +import type { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; +import type { SlskdIntegration } from "../download-client/slskd/slskd-integration"; +import type { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; +import type { EmbyIntegration } from "../emby/emby-integration"; +import type { GlancesIntegration } from "../glances/glances-integration"; +import type { GluetunIntegration } from "../gluetun/gluetun-integration"; +import type { GotifyIntegration } from "../gotify/gotify-integration"; +import type { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; +import type { ICalIntegration } from "../ical/ical-integration"; +import type { ImmichIntegration } from "../immich/immich-integration"; +import type { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; +import type { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; +import type { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; +import type { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; +import type { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; +import type { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; +import type { TdarrIntegration } from "../media-transcoding/tdarr-integration"; +import type { MockIntegration } from "../mock/mock-integration"; +import type { NavidromeIntegration } from "../navidrome/navidrome-integration"; +import type { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; +import type { NTFYIntegration } from "../ntfy/ntfy-integration"; +import type { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; +import type { OPNsenseIntegration } from "../opnsense/opnsense-integration"; +import type { OverseerrIntegration } from "../overseerr/overseerr-integration"; +import type { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; +import type { PatchMonIntegration } from "../patchmon/patchmon-integration"; +import type { PeaNutIntegration } from "../peanut/peanut-integration"; +import type { PiHoleIntegrationV5 } from "../pi-hole/v5/pi-hole-integration-v5"; +import type { PiHoleIntegrationV6 } from "../pi-hole/v6/pi-hole-integration-v6"; +import type { PlexIntegration } from "../plex/plex-integration"; +import type { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; +import type { ProxmoxIntegration } from "../proxmox/proxmox-integration"; +import type { SeerrIntegration } from "../seerr/seerr-integration"; +import type { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; +import type { SynologyIntegration } from "../synology/synology-integration"; +import type { TechnitiumDnsIntegration } from "../technitium/technitium-integration"; +import type { TracearrIntegration } from "../tracearr/tracearr-integration"; +import type { TraefikIntegration } from "../traefik/traefik-integration"; +import type { TrueNasIntegration } from "../truenas/truenas-integration"; +import type { UmamiIntegration } from "../umami/umami-integration"; +import type { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; +import type { UnraidIntegration } from "../unraid/unraid-integration"; +import type { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; + +export interface IntegrationInstanceMap { + anchor: AnchorIntegration; + adGuardHome: AdGuardHomeIntegration; + archiveTeamWarrior: ArchiveTeamWarriorIntegration; + aria2: Aria2Integration; + audiobookshelf: AudiobookshelfIntegration; + bazarr: BazarrIntegration; + beszel: BeszelIntegration; + coolify: CoolifyIntegration; + dashDot: DashDotIntegration; + deluge: DelugeIntegration; + emby: EmbyIntegration; + glances: GlancesIntegration; + gluetun: GluetunIntegration; + gotify: GotifyIntegration; + homeAssistant: HomeAssistantIntegration; + ical: ICalIntegration; + immich: ImmichIntegration; + jellyfin: JellyfinIntegration; + jellyseerr: JellyseerrIntegration; + lidarr: LidarrIntegration; + mock: MockIntegration; + navidrome: NavidromeIntegration; + nextcloud: NextcloudIntegration; + ntfy: NTFYIntegration; + nzbGet: NzbGetIntegration; + openmediavault: OpenMediaVaultIntegration; + opnsense: OPNsenseIntegration; + overseerr: OverseerrIntegration; + paperlessNgx: PaperlessNgxIntegration; + patchmon: PatchMonIntegration; + peaNut: PeaNutIntegration; + piHole: PiHoleIntegrationV5 | PiHoleIntegrationV6; + plex: PlexIntegration; + prowlarr: ProwlarrIntegration; + proxmox: ProxmoxIntegration; + qBittorrent: QBitTorrentIntegration; + radarr: RadarrIntegration; + readarr: ReadarrIntegration; + sabNzbd: SabnzbdIntegration; + seerr: SeerrIntegration; + slskd: SlskdIntegration; + sonarr: SonarrIntegration; + speedtestTracker: SpeedtestTrackerIntegration; + synology: SynologyIntegration; + tdarr: TdarrIntegration; + technitiumDns: TechnitiumDnsIntegration; + tracearr: TracearrIntegration; + traefik: TraefikIntegration; + transmission: TransmissionIntegration; + truenas: TrueNasIntegration; + umami: UmamiIntegration; + unifiController: UnifiControllerIntegration; + unraid: UnraidIntegration; + uptimeKuma: UptimeKumaIntegration; +} + +export type IntegrationInstanceOfKind = TKind extends keyof IntegrationInstanceMap + ? IntegrationInstanceMap[TKind] + : never; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f172cec36..d754512e9 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -99,3 +99,4 @@ export { downloadClientItemSchema } from "./interfaces/downloads/download-client // Helpers export { createIntegrationAsync } from "./base/creator"; +export type { IntegrationInstanceOfKind } from "./base/kind-map"; diff --git a/packages/next-cache-handler/package.json b/packages/next-cache-handler/package.json new file mode 100644 index 000000000..22ffd62a4 --- /dev/null +++ b/packages/next-cache-handler/package.json @@ -0,0 +1,24 @@ +{ + "name": "@homarr/next-cache-handler", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "rm -rf .turbo node_modules", + "format": "oxfmt .", + "lint": "oxlint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ioredis": "catalog:", + "next": "catalog:" + }, + "devDependencies": { + "@homarr/tsconfig": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/next-cache-handler/src/index.ts b/packages/next-cache-handler/src/index.ts new file mode 100644 index 000000000..0f0354dc7 --- /dev/null +++ b/packages/next-cache-handler/src/index.ts @@ -0,0 +1,27 @@ +import type { CacheHandler } from "next/dist/server/lib/cache-handlers/types"; + +let handler: CacheHandler; + +const getHandler = async (): Promise => { + if (handler) return handler; + + const useRedis = process.env.REDIS_HOST || process.env.REDIS_IS_EXTERNAL === "true"; + if (useRedis) { + const { RedisCacheHandler } = await import("./redis-handler"); + handler = new RedisCacheHandler(); + } else { + const { createDefaultCacheHandler } = await import("next/dist/server/lib/cache-handlers/default"); + handler = createDefaultCacheHandler(32 * 1024 * 1024); + } + return handler; +}; + +export default { + get: async (...args: Parameters) => (await getHandler()).get(...args), + set: async (...args: Parameters) => (await getHandler()).set(...args), + refreshTags: async () => (await getHandler()).refreshTags(), + getExpiration: async (...args: Parameters) => + (await getHandler()).getExpiration(...args), + updateTags: async (...args: Parameters) => + (await getHandler()).updateTags(...args), +} satisfies CacheHandler; diff --git a/packages/next-cache-handler/src/redis-handler.ts b/packages/next-cache-handler/src/redis-handler.ts new file mode 100644 index 000000000..6018ccb0e --- /dev/null +++ b/packages/next-cache-handler/src/redis-handler.ts @@ -0,0 +1,184 @@ +import Redis from "ioredis"; + +import type { CacheHandler } from "next/dist/server/lib/cache-handlers/types"; + +type CacheEntry = Awaited> & {}; + +const BUILD_ID = process.env.__NEXT_BUILD_ID ?? "dev"; + +export class RedisCacheHandler implements CacheHandler { + private redis: Redis; + private pendingSets = new Map>(); + private tagCache = new Map(); + private loggedError = false; + + constructor() { + this.redis = new Redis({ + host: process.env.REDIS_HOST ?? "localhost", + port: Number(process.env.REDIS_PORT ?? 6379), + password: process.env.REDIS_PASSWORD || undefined, + db: Number(process.env.REDIS_CACHE_DB ?? 1), + lazyConnect: true, + retryStrategy: (times) => Math.min(times * 100, 3000), + }); + this.redis.on("error", () => { + if (!this.loggedError) { + console.warn("[next-cache-handler] Redis connection error, falling back to memory"); + this.loggedError = true; + } + }); + } + + private entryKey(cacheKey: string) { + return `nextCache:v1:${BUILD_ID}:${cacheKey}`; + } + + private tagKey(tag: string) { + return `nextCache:tag:${tag}`; + } + + async get(cacheKey: string, softTags: string[]): Promise { + const pending = this.pendingSets.get(cacheKey); + if (pending) return pending; + + try { + await this.redis.connect().catch(() => {}); + const raw = await this.redis.getBuffer(this.entryKey(cacheKey)); + if (!raw) return undefined; + + const parsed = JSON.parse(raw.toString()) as { + tags: string[]; + stale: number; + timestamp: number; + expire: number; + revalidate: number; + body: number[]; + }; + + const allTags = [...parsed.tags, ...softTags]; + const expiration = await this.getExpiration(allTags); + if (expiration !== 0 && expiration < parsed.timestamp) { + return undefined; + } + + const body = new Uint8Array(parsed.body); + return { + value: new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }), + tags: parsed.tags, + stale: parsed.stale, + timestamp: parsed.timestamp, + expire: parsed.expire, + revalidate: parsed.revalidate, + }; + } catch { + return undefined; + } + } + + async set(cacheKey: string, pendingEntry: Promise): Promise { + this.pendingSets.set(cacheKey, pendingEntry); + + try { + const entry = await pendingEntry; + const reader = entry.value.getReader(); + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const result = await reader.read(); + done = result.done; + if (result.value) chunks.push(result.value); + } + + const totalLength = chunks.reduce((acc, c) => acc + c.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.length; + } + + const serialized = JSON.stringify({ + tags: entry.tags, + stale: entry.stale, + timestamp: entry.timestamp, + expire: entry.expire, + revalidate: entry.revalidate, + body: Array.from(body), + }); + + const ttlMs = entry.expire * 1000; + await this.redis.connect().catch(() => {}); + if (ttlMs > 0) { + await this.redis.set(this.entryKey(cacheKey), serialized, "PX", ttlMs); + } else { + await this.redis.set(this.entryKey(cacheKey), serialized); + } + } catch { + // Fail open + } finally { + if (this.pendingSets.get(cacheKey) === pendingEntry) { + this.pendingSets.delete(cacheKey); + } + } + } + + async refreshTags(): Promise { + try { + await this.redis.connect().catch(() => {}); + const keys = await this.redis.keys("nextCache:tag:*"); + if (keys.length === 0) { + this.tagCache.clear(); + return; + } + const values = await this.redis.mget(...keys); + this.tagCache.clear(); + for (let i = 0; i < keys.length; i++) { + const tag = keys[i]!.replace("nextCache:tag:", ""); + const val = values[i]; + if (val) this.tagCache.set(tag, Number(val)); + } + } catch { + // Fail open — stale tag cache is better than crash + } + } + + async getExpiration(tags: string[]): Promise { + if (tags.length === 0) return 0; + + let maxTimestamp = 0; + for (const tag of tags) { + const cached = this.tagCache.get(tag); + if (cached && cached > maxTimestamp) { + maxTimestamp = cached; + } + } + return maxTimestamp; + } + + async updateTags(tags: string[], durations?: { expire?: number }): Promise { + if (tags.length === 0) return; + try { + await this.redis.connect().catch(() => {}); + const now = Date.now(); + const pipeline = this.redis.pipeline(); + for (const tag of tags) { + const key = this.tagKey(tag); + pipeline.set(key, String(now)); + if (durations?.expire) { + pipeline.pexpire(key, durations.expire * 1000); + } else { + pipeline.pexpire(key, 7 * 24 * 60 * 60 * 1000); + } + this.tagCache.set(tag, now); + } + await pipeline.exec(); + } catch { + // Fail open + } + } +} diff --git a/packages/next-cache-handler/src/types.ts b/packages/next-cache-handler/src/types.ts new file mode 100644 index 000000000..6f0b91d9d --- /dev/null +++ b/packages/next-cache-handler/src/types.ts @@ -0,0 +1 @@ +export type { CacheEntry, CacheHandler, Timestamp } from "next/dist/server/lib/cache-handlers/types"; diff --git a/packages/next-cache-handler/tsconfig.json b/packages/next-cache-handler/tsconfig.json new file mode 100644 index 000000000..82b695258 --- /dev/null +++ b/packages/next-cache-handler/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/redis/src/lib/channel-subscription-tracker.ts b/packages/redis/src/lib/channel-subscription-tracker.ts index 02adc6897..42f28af18 100644 --- a/packages/redis/src/lib/channel-subscription-tracker.ts +++ b/packages/redis/src/lib/channel-subscription-tracker.ts @@ -3,7 +3,8 @@ import { randomUUID } from "crypto"; import type { MaybePromise } from "@homarr/common/types"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createRedisConnection } from "./connection"; +import { getSubscriberClient, usesMemoryFallback } from "./connection"; +import { memorySubscribe } from "./memory-channel"; const logger = createLogger({ module: "channelSubscriptionTracker" }); @@ -18,7 +19,8 @@ type SubscriptionCallback = (message: string) => MaybePromise; */ export class ChannelSubscriptionTracker { private static subscriptions = new Map>(); - private static redis = createRedisConnection(); + private static redis = getSubscriberClient(); + private static memoryUnsubscribers = new Map void>(); private static listenerActive = false; /** @@ -30,19 +32,27 @@ export class ChannelSubscriptionTracker { public static subscribe(channelName: string, callback: SubscriptionCallback) { logger.debug("Adding redis channel callback", { channel: channelName }); - // We only want to activate the listener once - if (!this.listenerActive) { - this.activateListener(); - this.listenerActive = true; - } - const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map(); const id = randomUUID(); // If there are no subscriptions to the channel, subscribe to it if (channelSubscriptions.size === 0) { logger.debug("Subscribing to redis channel", { channel: channelName }); - void this.redis.subscribe(channelName); + if (usesMemoryFallback()) { + const unsub = memorySubscribe(channelName, (message) => { + this.dispatchMessage(channelName, message); + }); + this.memoryUnsubscribers.set(channelName, unsub); + } else { + if (!this.listenerActive) { + this.activateListener(); + this.listenerActive = true; + } + const redis = this.redis; + if (redis) { + void redis.subscribe(channelName); + } + } } logger.debug("Adding redis channel callback", { channel: channelName, id }); @@ -65,30 +75,44 @@ export class ChannelSubscriptionTracker { } logger.debug("Unsubscribing from redis channel", { channel: channelName }); - void this.redis.unsubscribe(channelName); + if (usesMemoryFallback()) { + this.memoryUnsubscribers.get(channelName)?.(); + this.memoryUnsubscribers.delete(channelName); + } else { + const redis = this.redis; + if (redis) { + void redis.unsubscribe(channelName); + } + } this.subscriptions.delete(channelName); }; } + private static dispatchMessage(channel: string, message: string) { + const channelSubscriptions = this.subscriptions.get(channel); + if (!channelSubscriptions) { + logger.warn("Received message on unknown channel", { channel }); + return; + } + + for (const [id, callback] of channelSubscriptions.entries()) { + // Don't log messages from the logging channel as it would create an infinite loop + if (channel !== "pubSub:logging") { + logger.debug("Calling subscription callback", { channel, id }); + } + void callback(message); + } + } + /** * Activates the listener for the redis client. */ private static activateListener() { logger.debug("Activating listener"); - this.redis.on("message", (channel, message) => { - const channelSubscriptions = this.subscriptions.get(channel); - if (!channelSubscriptions) { - logger.warn("Received message on unknown channel", { channel }); - return; - } - - for (const [id, callback] of channelSubscriptions.entries()) { - // Don't log messages from the logging channel as it would create an infinite loop - if (channel !== "pubSub:logging") { - logger.debug("Calling subscription callback", { channel, id }); - } - void callback(message); - } + const redis = this.redis; + if (!redis) return; + redis.on("message", (channel, message) => { + this.dispatchMessage(channel, message); }); } } diff --git a/packages/redis/src/lib/channel.spec.ts b/packages/redis/src/lib/channel.spec.ts index 0ec39a2a5..ed80dc8df 100644 --- a/packages/redis/src/lib/channel.spec.ts +++ b/packages/redis/src/lib/channel.spec.ts @@ -4,9 +4,12 @@ import { describe, expect, test, vi } from "vitest"; vi.mock("./connection", () => ({ createRedisConnection: () => null, + getDataClient: () => null, + getSubscriberClient: () => null, + usesMemoryFallback: () => true, })); -import { createLockChannel } from "./channel"; +import { createLockChannel, createSubPubChannel } from "./channel"; describe("createLockChannel", () => { test("degrades gracefully when Redis connections are disabled", async () => { @@ -19,3 +22,31 @@ describe("createLockChannel", () => { await expect(lock.releaseAsync(token)).resolves.toBeUndefined(); }); }); + +describe("createSubPubChannel with memory fallback", () => { + test("publish and subscribe deliver messages in-process", async () => { + const channel = createSubPubChannel<{ value: number }>("test-pubsub"); + + const received: { value: number }[] = []; + channel.subscribe((data) => { + received.push(data); + }); + + await channel.publishAsync({ value: 42 }); + + expect(received).toEqual([{ value: 42 }]); + await expect(channel.getLastDataAsync()).resolves.toEqual({ value: 42 }); + }); + + test("subscribe replays last persisted data", async () => { + const channel = createSubPubChannel("test-replay"); + await channel.publishAsync("hello"); + + const replayed: string[] = []; + channel.subscribe((data) => { + replayed.push(data); + }); + + expect(replayed).toEqual(["hello"]); + }); +}); diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 4731ccfa1..5df779525 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -3,10 +3,12 @@ import superjson from "superjson"; import { createId } from "@homarr/common"; import { ChannelSubscriptionTracker } from "./channel-subscription-tracker"; -import { createRedisConnection } from "./connection"; +import { getDataClient, usesMemoryFallback } from "./connection"; +import { memoryGetLast, memoryPublish } from "./memory-channel"; -const publisher = createRedisConnection(); -const lastDataClient = createRedisConnection(); +const LAST_DATA_TTL_SECONDS = 86_400; + +const dataClient = getDataClient(); /** * Creates a new pub/sub channel. @@ -23,11 +25,18 @@ export const createSubPubChannel = (name: string, { persist }: { persist: */ subscribe: (callback: (data: TData) => void) => { if (persist) { - void lastDataClient.get(lastChannelName).then((data) => { + if (usesMemoryFallback()) { + const data = memoryGetLast(lastChannelName); if (data) { callback(superjson.parse(data)); } - }); + } else if (dataClient) { + void dataClient.get(lastChannelName).then((data) => { + if (data) { + callback(superjson.parse(data)); + } + }); + } } return ChannelSubscriptionTracker.subscribe(channelName, (message) => { callback(superjson.parse(message)); @@ -38,20 +47,31 @@ export const createSubPubChannel = (name: string, { persist }: { persist: * @param data data to be published */ publishAsync: async (data: TData) => { + const serialized = superjson.stringify(data); if (persist) { - await lastDataClient.set(lastChannelName, superjson.stringify(data)); + if (usesMemoryFallback()) { + memoryPublish(lastChannelName, data); + } else if (dataClient) { + await dataClient.set(lastChannelName, serialized, "EX", LAST_DATA_TTL_SECONDS); + } + } + if (usesMemoryFallback()) { + memoryPublish(channelName, data); + } else if (dataClient) { + await dataClient.publish(channelName, serialized); } - await publisher.publish(channelName, superjson.stringify(data)); }, getLastDataAsync: async () => { - const data = await lastDataClient.get(lastChannelName); + if (usesMemoryFallback()) { + const data = memoryGetLast(lastChannelName); + return data ? superjson.parse(data) : null; + } + const data = await dataClient?.get(lastChannelName); return data ? superjson.parse(data) : null; }, }; }; -const getSetClient = createRedisConnection(); - /** * Creates a new redis channel for a list * @param name name of channel @@ -65,7 +85,8 @@ export const createListChannel = (name: string) => { * @returns an array of all items */ getAllAsync: async () => { - const items = await getSetClient.lrange(listChannelName, 0, -1); + if (!dataClient) return []; + const items = await dataClient.lrange(listChannelName, 0, -1); return items.map((item) => superjson.parse(item)); }, /** @@ -73,20 +94,23 @@ export const createListChannel = (name: string) => { * @param item item to remove */ removeAsync: async (item: TItem) => { - await getSetClient.lrem(listChannelName, 0, superjson.stringify(item)); + if (!dataClient) return; + await dataClient.lrem(listChannelName, 0, superjson.stringify(item)); }, /** * Clear all items from the channels list */ clearAsync: async () => { - await getSetClient.del(listChannelName); + if (!dataClient) return; + await dataClient.del(listChannelName); }, /** * Add an item to the channels list * @param item item to add */ addAsync: async (item: TItem) => { - await getSetClient.lpush(listChannelName, superjson.stringify(item)); + if (!dataClient) return; + await dataClient.lpush(listChannelName, superjson.stringify(item)); }, }; }; @@ -102,7 +126,8 @@ export const createGetSetChannel = (name: string) => { * @returns data or null if not found */ getAsync: async () => { - const data = await getSetClient.get(name); + if (!dataClient) return null; + const data = await dataClient.get(name); return data ? superjson.parse(data) : null; }, /** @@ -111,17 +136,19 @@ export const createGetSetChannel = (name: string) => { * @param options optional TTL in seconds */ setAsync: async (data: TData, options?: { ttlSeconds?: number }) => { + if (!dataClient) return; if (options?.ttlSeconds) { - await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds); + await dataClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds); return; } - await getSetClient.set(name, superjson.stringify(data)); + await dataClient.set(name, superjson.stringify(data)); }, /** * Remove data from the channel */ removeAsync: async () => { - await getSetClient.del(name); + if (!dataClient) return; + await dataClient.del(name); }, }; }; @@ -136,17 +163,15 @@ export const createLockChannel = (name: string) => { return { acquireAsync: async (ttlSeconds: number) => { const token = createId(); - const client = getSetClient as typeof getSetClient | null; - if (!client) return token; + if (!dataClient) return token; - const result = await client.set(name, token, "EX", ttlSeconds, "NX"); + const result = await dataClient.set(name, token, "EX", ttlSeconds, "NX"); return result === "OK" ? token : null; }, releaseAsync: async (token: string) => { - const client = getSetClient as typeof getSetClient | null; - if (!client) return; + if (!dataClient) return; - await client.eval( + await dataClient.eval( "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, name, @@ -157,9 +182,8 @@ export const createLockChannel = (name: string) => { }; export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { - const client = getSetClient as typeof getSetClient | null; - if (!client) return; - await client.del(`session-store:${integrationId}`); + if (!dataClient) return; + await dataClient.del(`session-store:${integrationId}`); }; /** @@ -167,11 +191,12 @@ export const invalidateIntegrationCacheAsync = async (integrationId: string): Pr */ export const createChannelEventHistoryOld = (channelName: string, maxElements = 15) => { const popElementsOverMaxAsync = async () => { - const length = await getSetClient.llen(channelName); + if (!dataClient) return; + const length = await dataClient.llen(channelName); if (length <= maxElements) { return; } - await getSetClient.ltrim(channelName, 0, maxElements - 1); + await dataClient.ltrim(channelName, 0, maxElements - 1); }; return { @@ -181,33 +206,39 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem }); }, publishAndPushAsync: async (data: TData) => { - await publisher.publish(channelName, superjson.stringify(data)); - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + if (!dataClient) return; + await dataClient.publish(channelName, superjson.stringify(data)); + await dataClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); await popElementsOverMaxAsync(); }, pushAsync: async (data: TData) => { - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + if (!dataClient) return; + await dataClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); await popElementsOverMaxAsync(); }, clearAsync: async () => { - await getSetClient.del(channelName); + if (!dataClient) return; + await dataClient.del(channelName); }, getLastAsync: async () => { - const length = await getSetClient.llen(channelName); - const data = await getSetClient.lrange(channelName, length - 1, length); + if (!dataClient) return null; + const length = await dataClient.llen(channelName); + const data = await dataClient.lrange(channelName, length - 1, length); if (data.length !== 1) return null; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!); }, getSliceAsync: async (startIndex: number, endIndex: number) => { - const range = await getSetClient.lrange(channelName, startIndex, endIndex); + if (!dataClient) return []; + const range = await dataClient.lrange(channelName, startIndex, endIndex); return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)); }, getSliceUntilTimeAsync: async (time: Date) => { - const length = await getSetClient.llen(channelName); + if (!dataClient) return []; + const length = await dataClient.llen(channelName); const items: TData[] = []; - const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1); + const itemsInCollection = await dataClient.lrange(channelName, 0, length - 1); for (let i = 0; i < length - 1; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -220,14 +251,13 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem return items; }, getLengthAsync: async () => { - return await getSetClient.llen(channelName); + if (!dataClient) return 0; + return await dataClient.llen(channelName); }, name: channelName, }; }; -const queueClient = createRedisConnection(); - type WithId = TItem & { _id: string }; /** @@ -238,11 +268,13 @@ type WithId = TItem & { _id: string }; export const createQueueChannel = (name: string) => { const queueChannelName = `queue:${name}`; const getDataAsync = async () => { - const data = await queueClient.get(queueChannelName); + if (!dataClient) return []; + const data = await dataClient.get(queueChannelName); return data ? superjson.parse[]>(data) : []; }; const setDataAsync = async (data: WithId[]) => { - await queueClient.set(queueChannelName, superjson.stringify(data)); + if (!dataClient) return; + await dataClient.set(queueChannelName, superjson.stringify(data)); }; return { @@ -289,5 +321,6 @@ export const createQueueChannel = (name: string) => { }; export const handshakeAsync = async () => { - await getSetClient.hello(); + if (!dataClient) return; + await dataClient.hello(); }; diff --git a/packages/redis/src/lib/connection.ts b/packages/redis/src/lib/connection.ts index ba945de33..6df3649cf 100644 --- a/packages/redis/src/lib/connection.ts +++ b/packages/redis/src/lib/connection.ts @@ -1,15 +1,32 @@ import type { RedisClient } from "@homarr/core/infrastructure/redis"; import { createRedisClient } from "@homarr/core/infrastructure/redis"; -/** - * Creates a new Redis connection - * @returns redis client - */ -export const createRedisConnection = () => { - if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) { - // Return null if we are in CI as we don't want to connect to Redis - return null as unknown as RedisClient; - } +let dataClient: RedisClient | undefined; +let subscriberClient: RedisClient | undefined; - return createRedisClient(); +const isRedisExplicitlyDisabled = () => + Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS); + +/** + * In-process pub/sub fallback for single-instance deployments when Redis is unavailable. + * Never used when REDIS_IS_EXTERNAL=true (multi-instance requires real Redis). + */ +export const usesMemoryFallback = (): boolean => + process.env.REDIS_IS_EXTERNAL !== "true" && isRedisExplicitlyDisabled(); + +/** Shared client for SET/GET/PUBLISH and other non-subscribe Redis commands. */ +export const getDataClient = (): RedisClient | null => { + if (usesMemoryFallback()) return null; + dataClient ??= createRedisClient(); + return dataClient; }; + +/** Dedicated client for SUBSCRIBE — ioredis requires a separate connection for pub/sub. */ +export const getSubscriberClient = (): RedisClient | null => { + if (usesMemoryFallback()) return null; + subscriberClient ??= createRedisClient(); + return subscriberClient; +}; + +/** @deprecated Prefer getDataClient() — kept for test mocks. */ +export const createRedisConnection = () => getDataClient(); diff --git a/packages/redis/src/lib/memory-channel.ts b/packages/redis/src/lib/memory-channel.ts new file mode 100644 index 000000000..16975a3bb --- /dev/null +++ b/packages/redis/src/lib/memory-channel.ts @@ -0,0 +1,34 @@ +import EventEmitter from "node:events"; + +import superjson from "superjson"; + +const emitter = new EventEmitter(); +emitter.setMaxListeners(100); + +const lastData = new Map(); +const MAX_ENTRIES = 500; + +export const memoryPublish = (channel: string, data: unknown) => { + const serialized = superjson.stringify(data); + lastData.set(channel, { data: serialized, timestamp: Date.now() }); + + // Bounded: evict oldest if over limit + if (lastData.size > MAX_ENTRIES) { + const oldest = lastData.keys().next().value; + if (oldest) lastData.delete(oldest); + } + + emitter.emit(channel, serialized); +}; + +export const memorySubscribe = (channel: string, callback: (data: string) => void) => { + emitter.on(channel, callback); + return () => { + emitter.off(channel, callback); + }; +}; + +export const memoryGetLast = (channel: string): string | null => { + const entry = lastData.get(channel); + return entry?.data ?? null; +}; diff --git a/packages/translation/src/request.ts b/packages/translation/src/request.ts index 1d680e50b..3d9be60fd 100644 --- a/packages/translation/src/request.ts +++ b/packages/translation/src/request.ts @@ -6,51 +6,46 @@ import { fallbackLocale, isLocaleSupported } from "."; import type { SupportedLanguage } from "./config"; import { createLanguageMapping } from "./mapping"; -// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration +// ponytail: module-level memo — one merged object per locale used (~250-400 KB each). +// Ceiling: ~2 MB for 5 active locales. Upgrade: evict when size > 8. +const mergedMessages = new Map>(); + +const loadMessagesAsync = (locale: SupportedLanguage): Promise => { + const existing = mergedMessages.get(locale); + if (existing) return existing; + + const promise = (async () => { + const languageMap = createLanguageMapping(); + const current = removeEmptyTranslations((await languageMap[locale]()).default) as TranslationObject; + if (locale === fallbackLocale) return current; + const fallback = (await languageMap[fallbackLocale]()).default; + return deepmerge(fallback, current) as TranslationObject; + })(); + + mergedMessages.set(locale, promise); + void promise.catch(() => { + if (mergedMessages.get(locale) === promise) mergedMessages.delete(locale); + }); + return promise; +}; + export default getRequestConfig(async ({ requestLocale }) => { - let currentLocale = await requestLocale; - - if (!currentLocale || !isLocaleSupported(currentLocale)) { - currentLocale = fallbackLocale; - } - const typedLocale = currentLocale as SupportedLanguage; - - const languageMap = createLanguageMapping(); - const currentMessages = removeEmptyTranslations((await languageMap[typedLocale]()).default) as TranslationObject; - - // Fallback to default locale if the current locales messages if not all messages are present - if (currentLocale !== fallbackLocale) { - const fallbackMessages = (await languageMap[fallbackLocale]()).default; - return { - locale: typedLocale, - messages: deepmerge(fallbackMessages, currentMessages), - }; - } - - return { - locale: currentLocale, - messages: currentMessages, - }; + const requested = await requestLocale; + const locale: SupportedLanguage = + requested && isLocaleSupported(requested) ? (requested as SupportedLanguage) : fallbackLocale; + return { locale, messages: await loadMessagesAsync(locale) }; }); const removeEmptyTranslations = (translations: Record): Record => { return Object.entries(translations).reduce( (acc, [key, value]) => { if (typeof value !== "string") { - return { - ...acc, - [key]: removeEmptyTranslations(value as Record), - }; + return { ...acc, [key]: removeEmptyTranslations(value as Record) }; } - if (value.trim() === "") { return acc; } - - return { - ...acc, - [key]: value, - }; + return { ...acc, [key]: value }; }, {} as Record, ); diff --git a/packages/widgets/index.ts b/packages/widgets/index.ts deleted file mode 100644 index 3bd16e178..000000000 --- a/packages/widgets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./src"; diff --git a/packages/widgets/package.json b/packages/widgets/package.json index d3e516202..307ee71ce 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -12,12 +12,12 @@ } }, "exports": { - ".": "./index.ts", "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx", "./custom-api/component": "./src/custom-api/component.tsx", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", "./catalog": "./src/catalog.ts", "./definition": "./src/definition.ts", + "./import": "./src/import.ts", "./errors": "./src/errors/component.tsx", "./manifest": "./src/manifest.ts", "./options": "./src/options.ts", diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 12482f511..1e71ffbf8 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -9,7 +9,7 @@ import type { SettingsContextProps } from "@homarr/settings/creator"; import type { stringOrTranslation } from "@homarr/translation"; import type { TablerIcon } from "@homarr/ui"; -import type { WidgetImports } from "."; +import type { WidgetImports } from "./import"; import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; export interface WidgetContextMenuAction { diff --git a/packages/widgets/src/import.ts b/packages/widgets/src/import.ts index 911871a38..f2d74be6c 100644 --- a/packages/widgets/src/import.ts +++ b/packages/widgets/src/import.ts @@ -1,3 +1,129 @@ -import type { WidgetKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +import type * as anchorNote from "./anchor-note"; +import type * as app from "./app"; +import type * as archiveTeamWarrior from "./archive-team-warrior"; +import type * as audioStats from "./audio-stats"; +import type * as bazarr from "./bazarr"; +import type * as beszelAlerts from "./beszel-alerts"; +import type * as beszelSystemGrid from "./beszel-system-grid"; +import type * as beszelSystemStats from "./beszel-system-stats"; +import type * as beszelSystemTable from "./beszel-system-table"; +import type * as bookmarks from "./bookmarks"; +import type * as calendar from "./calendar"; +import type * as clock from "./clock"; +import type * as coolify from "./coolify"; +import type * as customApi from "./custom-api"; +import type * as dnsHoleControls from "./dns-hole/controls"; +import type * as dnsHoleSummary from "./dns-hole/summary"; +import type * as dockerContainers from "./docker"; +import type * as downloads from "./downloads"; +import type * as firewall from "./firewall"; +import type * as healthMonitoring from "./health-monitoring"; +import type * as iframe from "./iframe"; +import type * as immichAlbumCarousel from "./immich/album-carousel"; +import type * as immichServerStats from "./immich/server-stats"; +import type * as indexerManager from "./indexer-manager"; +import type * as mediaMissing from "./media-missing"; +import type * as mediaReleases from "./media-releases"; +import type * as mediaRequestsList from "./media-requests/list"; +import type * as mediaRequestsStats from "./media-requests/stats"; +import type * as mediaServer from "./media-server"; +import type * as mediaTranscoding from "./media-transcoding"; +import type * as minecraftServerStatus from "./minecraft/server-status"; +import type * as networkControllerStatus from "./network-controller/network-status"; +import type * as networkControllerSummary from "./network-controller/summary"; +import type * as notebook from "./notebook"; +import type * as notifications from "./notifications"; +import type * as paperlessNgx from "./paperless-ngx"; +import type * as patchmon from "./patchmon"; +import type * as releases from "./releases"; +import type * as rssFeed from "./rssFeed"; +import type * as smartHomeEntityState from "./smart-home/entity-state"; +import type * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; +import type * as speedtestTracker from "./speedtest-tracker"; +import type * as stockPrice from "./stocks"; +import type * as systemDisks from "./system-disks"; +import type * as systemResources from "./system-resources"; +import type * as timetable from "./timetable"; +import type * as tracearr from "./tracearr"; +import type * as traefik from "./traefik"; +import type * as umami from "./umami"; +import type * as uptimeKuma from "./uptime-kuma"; +import type * as ups from "./ups"; +import type * as video from "./video"; +import type * as vpn from "./vpn"; +import type * as weather from "./weather"; export type WidgetImportRecord = Record; + +export type WidgetImports = { + clock: typeof clock; + weather: typeof weather; + app: typeof app; + archiveTeamWarrior: typeof archiveTeamWarrior; + anchorNote: typeof anchorNote; + notebook: typeof notebook; + iframe: typeof iframe; + video: typeof video; + dnsHoleSummary: typeof dnsHoleSummary; + dnsHoleControls: typeof dnsHoleControls; + "smartHome-entityState": typeof smartHomeEntityState; + "smartHome-executeAutomation": typeof smartHomeExecuteAutomation; + stockPrice: typeof stockPrice; + mediaServer: typeof mediaServer; + calendar: typeof calendar; + downloads: typeof downloads; + "mediaRequests-requestList": typeof mediaRequestsList; + "mediaRequests-requestStats": typeof mediaRequestsStats; + mediaMissing: typeof mediaMissing; + networkControllerSummary: typeof networkControllerSummary; + networkControllerStatus: typeof networkControllerStatus; + rssFeed: typeof rssFeed; + bookmarks: typeof bookmarks; + bazarr: typeof bazarr; + indexerManager: typeof indexerManager; + healthMonitoring: typeof healthMonitoring; + mediaTranscoding: typeof mediaTranscoding; + minecraftServerStatus: typeof minecraftServerStatus; + dockerContainers: typeof dockerContainers; + releases: typeof releases; + firewall: typeof firewall; + notifications: typeof notifications; + mediaReleases: typeof mediaReleases; + systemResources: typeof systemResources; + coolify: typeof coolify; + systemDisks: typeof systemDisks; + timetable: typeof timetable; + "immich-serverStats": typeof immichServerStats; + "immich-albumCarousel": typeof immichAlbumCarousel; + paperlessNgx: typeof paperlessNgx; + patchmon: typeof patchmon; + tracearr: typeof tracearr; + speedtestTracker: typeof speedtestTracker; + uptimeKuma: typeof uptimeKuma; + audioStats: typeof audioStats; + umami: typeof umami; + vpn: typeof vpn; + ups: typeof ups; + beszelSystemTable: typeof beszelSystemTable; + beszelSystemGrid: typeof beszelSystemGrid; + beszelAlerts: typeof beszelAlerts; + beszelSystemStats: typeof beszelSystemStats; + traefik: typeof traefik; + customApi: typeof customApi; +}; + +export type WidgetImportKey = keyof WidgetImports; + +export type inferSupportedIntegrations = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: string[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : string[])[number]; + +export type inferSupportedIntegrationsStrict = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: IntegrationKind[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : never[])[number]; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx deleted file mode 100644 index 061fc4b2d..000000000 --- a/packages/widgets/src/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import type { ComponentType } from "react"; -import type { Loader } from "next/dynamic"; -import dynamic from "next/dynamic"; -import { Center, Loader as UiLoader } from "@mantine/core"; - -import { objectEntries } from "@homarr/common"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings/creator"; - -import * as anchorNote from "./anchor-note"; -import * as audioStats from "./audio-stats"; -import * as app from "./app"; -import * as archiveTeamWarrior from "./archive-team-warrior"; -import * as bookmarks from "./bookmarks"; -import * as bazarr from "./bazarr"; -import * as calendar from "./calendar"; -import * as clock from "./clock"; -import * as coolify from "./coolify"; -import type { WidgetComponentProps } from "./definition"; -import * as dnsHoleControls from "./dns-hole/controls"; -import * as dnsHoleSummary from "./dns-hole/summary"; -import * as dockerContainers from "./docker"; -import * as downloads from "./downloads"; -import * as firewall from "./firewall"; -import * as healthMonitoring from "./health-monitoring"; -import * as iframe from "./iframe"; -import * as immichAlbumCarousel from "./immich/album-carousel"; -import * as immichServerStats from "./immich/server-stats"; -import type { WidgetImportRecord } from "./import"; -import * as indexerManager from "./indexer-manager"; -import * as mediaReleases from "./media-releases"; -import * as mediaRequestsList from "./media-requests/list"; -import * as mediaRequestsStats from "./media-requests/stats"; -import * as mediaServer from "./media-server"; -import * as mediaTranscoding from "./media-transcoding"; -import * as mediaMissing from "./media-missing"; -import * as minecraftServerStatus from "./minecraft/server-status"; -import * as networkControllerStatus from "./network-controller/network-status"; -import * as networkControllerSummary from "./network-controller/summary"; -import * as notebook from "./notebook"; -import * as paperlessNgx from "./paperless-ngx"; -import * as patchmon from "./patchmon"; -import * as notifications from "./notifications"; -import type { WidgetOptionDefinition } from "./options"; -import * as releases from "./releases"; -import * as rssFeed from "./rssFeed"; -import * as smartHomeEntityState from "./smart-home/entity-state"; -import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; -import * as speedtestTracker from "./speedtest-tracker"; -import * as uptimeKuma from "./uptime-kuma"; -import * as stockPrice from "./stocks"; -import * as systemDisks from "./system-disks"; -import * as systemResources from "./system-resources"; -import * as timetable from "./timetable"; -import * as traefik from "./traefik"; -import * as tracearr from "./tracearr"; -import * as umami from "./umami"; -import * as ups from "./ups"; -import * as vpn from "./vpn"; -import * as beszelSystemTable from "./beszel-system-table"; -import * as beszelSystemGrid from "./beszel-system-grid"; -import * as beszelAlerts from "./beszel-alerts"; -import * as beszelSystemStats from "./beszel-system-stats"; -import * as video from "./video"; -import * as weather from "./weather"; -import * as customApi from "./custom-api"; - -export type { - WidgetDefinition, - WidgetContextMenuAction, - WidgetContextActionProps, - WidgetOptionsSettings, -} from "./definition"; -export type { WidgetComponentProps }; -export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; - -export const widgetImports = { - clock, - weather, - app, - archiveTeamWarrior, - anchorNote, - notebook, - iframe, - video, - dnsHoleSummary, - dnsHoleControls, - "smartHome-entityState": smartHomeEntityState, - "smartHome-executeAutomation": smartHomeExecuteAutomation, - stockPrice, - mediaServer, - calendar, - downloads, - "mediaRequests-requestList": mediaRequestsList, - "mediaRequests-requestStats": mediaRequestsStats, - mediaMissing, - networkControllerSummary, - networkControllerStatus, - rssFeed, - bookmarks, - bazarr, - indexerManager, - healthMonitoring, - mediaTranscoding, - minecraftServerStatus, - dockerContainers, - releases, - firewall, - notifications, - mediaReleases, - systemResources, - coolify, - systemDisks, - timetable, - "immich-serverStats": immichServerStats, - "immich-albumCarousel": immichAlbumCarousel, - paperlessNgx, - patchmon, - tracearr, - speedtestTracker, - uptimeKuma, - audioStats, - umami, - vpn, - ups, - beszelSystemTable, - beszelSystemGrid, - beszelAlerts, - beszelSystemStats, - traefik, - customApi, -} satisfies WidgetImportRecord; - -export type WidgetImports = typeof widgetImports; -export type WidgetImportKey = keyof WidgetImports; - -const loadedComponents = new Map>>(); - -export const loadWidgetDynamic = (kind: TKind) => { - const existingComponent = loadedComponents.get(kind); - if (existingComponent) return existingComponent; - - const newlyLoadedComponent = dynamic>( - widgetImports[kind].componentLoader as Loader>, - { - loading: () => ( -
- -
- ), - }, - ); - - loadedComponents.set(kind, newlyLoadedComponent as never); - return newlyLoadedComponent; -}; - -export type inferSupportedIntegrations = (WidgetImports[TKind]["definition"] extends { - supportedIntegrations: string[]; -} - ? WidgetImports[TKind]["definition"]["supportedIntegrations"] - : string[])[number]; - -export type inferSupportedIntegrationsStrict = (WidgetImports[TKind]["definition"] extends { - supportedIntegrations: IntegrationKind[]; -} - ? WidgetImports[TKind]["definition"]["supportedIntegrations"] - : never[])[number]; - -export const reduceWidgetOptionsWithDefaultValues = ( - kind: WidgetKind, - settings: Pick, - currentValue: Record = {}, -) => { - const definition = widgetImports[kind].definition; - const options = definition.createOptions(settings) as Record; - return objectEntries(options).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: currentValue[key] ?? value.defaultValue, - }), - {} as Record, - ); -}; diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index f80f4349c..b5dde8aff 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -1,10 +1,10 @@ import type { QueryKey } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; +import { createRetryableLoader } from "@homarr/common"; import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions"; import { - createRetryableLoader, loadAllWidgetDefinitions, loadWidgetComponent, loadWidgetDefinition, diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 4bb2cdf2d..60840623e 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -1,6 +1,6 @@ import type { ComponentType } from "react"; -import { objectEntries } from "@homarr/common"; +import { createRetryableLoader, objectEntries } from "@homarr/common"; import type { WidgetKind } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; @@ -143,25 +143,6 @@ const definitionPromises = new Map>(); export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[]; -export const createRetryableLoader = (loaders: ReadonlyMap Promise>) => { - const promises = new Map>(); - - return (key: TKey) => { - const existing = promises.get(key); - if (existing) return existing; - - const loader = loaders.get(key); - if (!loader) throw new Error(`No loader is registered for ${String(key)}`); - - const promise = loader(); - promises.set(key, promise); - void promise.catch(() => { - if (promises.get(key) === promise) promises.delete(key); - }); - return promise; - }; -}; - export const loadWidgetModule = createRetryableLoader( new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise][]), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d620d34f4..f60231d28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,9 @@ catalogs: dockerode: specifier: ^5.0.1 version: 5.0.1 + dompurify: + specifier: ^3.4.13 + version: 3.4.13 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -353,7 +356,7 @@ catalogs: version: 0.8.3 esbuild: specifier: ^0.28.0 - version: 0.28.0 + version: 0.28.1 fast-xml-parser: specifier: ^5.9.3 version: 5.9.3 @@ -372,9 +375,6 @@ catalogs: ioredis: specifier: 5.11.1 version: 5.11.1 - isomorphic-dompurify: - specifier: ^3.18.0 - version: 3.18.0 jotai: specifier: ^2.20.1 version: 2.20.1 @@ -393,6 +393,9 @@ catalogs: ldapts: specifier: 8.1.8 version: 8.1.8 + linkedom: + specifier: ^0.18.13 + version: 0.18.13 lodash.clonedeep: specifier: ^4.5.0 version: 4.5.0 @@ -857,6 +860,9 @@ importers: '@homarr/modals-collection': specifier: workspace:^0.1.0 version: link:../../packages/modals-collection + '@homarr/next-cache-handler': + specifier: workspace:^0.1.0 + version: link:../../packages/next-cache-handler '@homarr/notifications': specifier: workspace:^0.1.0 version: link:../../packages/notifications @@ -974,6 +980,9 @@ importers: dayjs: specifier: 'catalog:' version: 1.11.21 + dompurify: + specifier: 'catalog:' + version: 3.4.13 dotenv: specifier: 'catalog:' version: 17.4.2 @@ -986,15 +995,15 @@ importers: glob: specifier: 'catalog:' version: 13.0.6 - isomorphic-dompurify: - specifier: 'catalog:' - version: 3.18.0(@noble/hashes@2.0.1) jotai: specifier: 'catalog:' version: 2.20.1(@babel/core@7.29.0(supports-color@10.2.2))(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) jszip: specifier: 'catalog:' version: 3.10.1 + linkedom: + specifier: 'catalog:' + version: 0.18.13 lucide-react: specifier: 'catalog:' version: 1.24.0(react@19.2.7) @@ -1167,7 +1176,7 @@ importers: version: 11.0.0 esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 tsx: specifier: 'catalog:' version: 4.20.4 @@ -1219,7 +1228,7 @@ importers: version: 8.18.1 esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 typescript: specifier: 'catalog:' version: 7.0.2 @@ -1470,7 +1479,7 @@ importers: version: link:../../tooling/typescript esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 typescript: specifier: 'catalog:' version: 7.0.2 @@ -1734,7 +1743,7 @@ importers: version: 11.0.0 esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 tsx: specifier: 'catalog:' version: 4.20.4 @@ -2078,6 +2087,25 @@ importers: specifier: 'catalog:' version: 7.0.2 + packages/next-cache-handler: + dependencies: + ioredis: + specifier: 'catalog:' + version: 5.11.1(supports-color@10.2.2) + next: + specifier: 'catalog:' + version: 16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) + devDependencies: + '@homarr/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) + packages/notifications: dependencies: '@homarr/ui': @@ -4275,12 +4303,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -4299,12 +4321,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} @@ -4323,12 +4339,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} @@ -4347,12 +4357,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} @@ -4371,12 +4375,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} @@ -4395,12 +4393,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} @@ -4419,12 +4411,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} @@ -4443,12 +4429,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} @@ -4467,12 +4447,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} @@ -4491,12 +4465,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} @@ -4515,12 +4483,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} @@ -4539,12 +4501,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} @@ -4563,12 +4519,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} @@ -4587,12 +4537,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} @@ -4611,12 +4555,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} @@ -4635,12 +4573,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} @@ -4659,12 +4591,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -4683,12 +4609,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} @@ -4707,12 +4627,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -4731,12 +4645,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} @@ -4755,12 +4663,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -4779,12 +4681,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} @@ -4803,12 +4699,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} @@ -4827,12 +4717,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} @@ -4851,12 +4735,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} @@ -4875,12 +4753,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -5863,9 +5735,6 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} @@ -7947,9 +7816,6 @@ packages: '@types/node@18.19.50': resolution: {integrity: sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} - '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -8213,10 +8079,6 @@ packages: peerDependencies: video.js: ^8.19.0 - '@videojs/vhs-utils@4.1.1': - resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==} - engines: {node: '>=8', npm: '>=5'} - '@videojs/vhs-utils@4.1.2': resolution: {integrity: sha512-1Dsv5dtWOq0LP1G37O46FQFA2tKAZON5UMe6dFPxT7JvPhEx8QIXcFQxan8+hHI3fX6aomFRFrUBJNgRYQDSXA==} engines: {node: '>=8', npm: '>=5'} @@ -8888,6 +8750,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -8917,6 +8783,7 @@ packages: browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} @@ -9407,6 +9274,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} @@ -9479,6 +9347,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + css-selector-parser@3.3.0: resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} @@ -9498,6 +9370,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + cssdb@8.9.0: resolution: {integrity: sha512-J8jOU/hLjaXcO1LldOLraJSQpfLXRKof0I7mtbRyOy2AAXgqst0x9rlgi2qXeD6d0ou3ZLqcPAMqYVbpCbrxEw==} @@ -9534,6 +9410,9 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -9866,10 +9745,6 @@ packages: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} - dockerode@5.0.0: - resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==} - engines: {node: '>= 14.17'} - dockerode@5.0.1: resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} engines: {node: '>= 14.17'} @@ -9891,12 +9766,20 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -9905,15 +9788,26 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -10181,11 +10075,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -10397,10 +10286,6 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@5.8.0: - resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} - hasBin: true - fast-xml-parser@5.9.3: resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} hasBin: true @@ -10895,6 +10780,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier-terser@6.1.0: resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} engines: {node: '>=12'} @@ -10902,6 +10790,7 @@ packages: html-minifier-terser@7.2.0: resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} @@ -10997,10 +10886,6 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - human-signals@8.0.0: - resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} - engines: {node: '>=18.18.0'} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -11051,9 +10936,6 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@11.1.4: - resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} - immer@11.1.9: resolution: {integrity: sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==} @@ -11335,10 +11217,6 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - isomorphic-dompurify@3.18.0: - resolution: {integrity: sha512-ajp0D8laIHeoYlhBTevpE2HUhqWaqLXFk6K/wV3Ok8kDraBZpZsifwVWaY8IfJntMRIo1VSksgKV+lXyet9Q7A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} - isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: @@ -11454,6 +11332,7 @@ packages: jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} + hasBin: true json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -11649,6 +11528,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} @@ -12459,6 +12347,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + null-loader@4.0.1: resolution: {integrity: sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==} engines: {node: '>= 10.13.0'} @@ -12813,10 +12705,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -13875,9 +13763,6 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -14424,9 +14309,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} - strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} @@ -14505,6 +14387,7 @@ packages: svgo@3.3.3: resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} engines: {node: '>=14.0.0'} + hasBin: true svgson@5.3.1: resolution: {integrity: sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==} @@ -14547,9 +14430,6 @@ packages: tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - tar-fs@3.1.2: - resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} - tar-fs@3.1.3: resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} @@ -14614,6 +14494,7 @@ packages: terser@5.46.1: resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} engines: {node: '>=10'} + hasBin: true testcontainers@12.0.4: resolution: {integrity: sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==} @@ -14847,10 +14728,6 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.6.0: - resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} - engines: {node: '>=20'} - type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -14879,6 +14756,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -15528,10 +15408,6 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} - yoctocolors@2.1.1: - resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} - engines: {node: '>=18'} - yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} @@ -17096,7 +16972,7 @@ snapshots: cookie-es: 3.0.1 node-fetch-native: 1.6.7 ofetch: 1.5.1 - type-fest: 5.6.0 + type-fest: 5.7.0 ufo: 1.6.4 uint8array-extras: 1.5.0 @@ -17113,7 +16989,7 @@ snapshots: cookie: 1.1.1 node-fetch-native: 1.6.7 ofetch: 1.5.1 - type-fest: 5.6.0 + type-fest: 5.7.0 ufo: 1.6.4 uint8array-extras: 1.5.0 @@ -17228,7 +17104,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/bundler@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(uglify-js@3.19.3))(@rspack/core@1.7.11)(@swc/core@1.15.3)(@swc/html@1.15.40)(csso@5.0.5)(esbuild@0.28.0)(lightningcss@1.32.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@7.0.2)(uglify-js@3.19.3)': + '@docusaurus/bundler@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(uglify-js@3.19.3))(@rspack/core@1.7.11)(@swc/core@1.15.3)(@swc/html@1.15.40)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.32.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@7.0.2)(uglify-js@3.19.3)': dependencies: '@babel/core': 7.29.0(supports-color@10.2.2) '@docusaurus/babel': 3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3) @@ -17240,7 +17116,7 @@ snapshots: clean-css: 5.3.3 copy-webpack-plugin: 11.0.0(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) css-loader: 6.11.0(@rspack/core@1.7.11)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) - css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.32.0)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) cssnano: 6.1.2(postcss@8.5.16) file-loader: 6.2.0(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) html-minifier-terser: 7.2.0 @@ -17249,7 +17125,7 @@ snapshots: postcss: 8.5.16 postcss-loader: 7.3.4(postcss@8.5.16)(typescript@7.0.2)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) postcss-preset-env: 10.6.1(postcss@8.5.16) - terser-webpack-plugin: 5.6.0(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.0)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) + terser-webpack-plugin: 5.6.0(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)))(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)) webpack: 5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3) @@ -17276,7 +17152,7 @@ snapshots: '@docusaurus/core@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(uglify-js@3.19.3))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@rspack/core@1.7.11)(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(debug@4.4.3(supports-color@10.2.2))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@7.0.2)(uglify-js@3.19.3)': dependencies: '@docusaurus/babel': 3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3) - '@docusaurus/bundler': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(uglify-js@3.19.3))(@rspack/core@1.7.11)(@swc/core@1.15.3)(@swc/html@1.15.40)(csso@5.0.5)(esbuild@0.28.0)(lightningcss@1.32.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@7.0.2)(uglify-js@3.19.3) + '@docusaurus/bundler': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(uglify-js@3.19.3))(@rspack/core@1.7.11)(@swc/core@1.15.3)(@swc/html@1.15.40)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.32.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@7.0.2)(uglify-js@3.19.3) '@docusaurus/logger': 3.10.1 '@docusaurus/mdx-loader': 3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3) '@docusaurus/utils': 3.10.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(uglify-js@3.19.3) @@ -18206,9 +18082,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/aix-ppc64@0.28.0': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -18218,9 +18091,6 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm64@0.28.0': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true @@ -18230,9 +18100,6 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-arm@0.28.0': - optional: true - '@esbuild/android-arm@0.28.1': optional: true @@ -18242,9 +18109,6 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/android-x64@0.28.0': - optional: true - '@esbuild/android-x64@0.28.1': optional: true @@ -18254,9 +18118,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.28.0': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true @@ -18266,9 +18127,6 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/darwin-x64@0.28.0': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true @@ -18278,9 +18136,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.28.0': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true @@ -18290,9 +18145,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.28.0': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true @@ -18302,9 +18154,6 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.28.0': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true @@ -18314,9 +18163,6 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-arm@0.28.0': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true @@ -18326,9 +18172,6 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-ia32@0.28.0': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true @@ -18338,9 +18181,6 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-loong64@0.28.0': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true @@ -18350,9 +18190,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-mips64el@0.28.0': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true @@ -18362,9 +18199,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.28.0': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true @@ -18374,9 +18208,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.28.0': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true @@ -18386,9 +18217,6 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-s390x@0.28.0': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true @@ -18398,9 +18226,6 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.28.0': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true @@ -18410,9 +18235,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.28.0': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true @@ -18422,9 +18244,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.28.0': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true @@ -18434,9 +18253,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.28.0': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true @@ -18446,9 +18262,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.28.0': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true @@ -18458,9 +18271,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.28.0': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true @@ -18470,9 +18280,6 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.28.0': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true @@ -18482,9 +18289,6 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.28.0': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true @@ -18494,9 +18298,6 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-ia32@0.28.0': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true @@ -18506,9 +18307,6 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@esbuild/win32-x64@0.28.0': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -18519,7 +18317,7 @@ snapshots: '@extractus/feed-extractor@7.2.1': dependencies: '@pwshub/bellajs': 13.0.2 - fast-xml-parser: 5.8.0 + fast-xml-parser: 5.9.3 html-entities: 2.6.0 '@fastify/ajv-compiler@4.0.5': @@ -18995,7 +18793,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -19163,7 +18961,7 @@ snapshots: '@kubernetes/client-node@1.4.0(supports-color@10.2.2)': dependencies: '@types/js-yaml': 4.0.9 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/node-fetch': 2.6.13 '@types/stream-buffers': 3.0.7 form-data: 4.0.6 @@ -19176,7 +18974,7 @@ snapshots: rfc4648: 1.5.4 socks-proxy-agent: 8.0.5(supports-color@10.2.2) stream-buffers: 3.0.3 - tar-fs: 3.1.2 + tar-fs: 3.1.3 ws: 8.21.0 transitivePeerDependencies: - bare-buffer @@ -19646,8 +19444,6 @@ snapshots: '@noble/hashes@2.0.1': {} - '@nodable/entities@2.1.0': {} - '@nodable/entities@2.2.0': {} '@nodelib/fs.scandir@2.1.5': @@ -20226,10 +20022,10 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 - immer: 11.1.4 + immer: 11.1.9 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) - reselect: 5.1.1 + reselect: 5.2.0 optionalDependencies: react: 19.2.7 react-redux: 9.2.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) @@ -20570,7 +20366,7 @@ snapshots: '@scalar/icons@0.7.3(typescript@7.0.2)': dependencies: '@phosphor-icons/core': 2.1.1 - '@types/node': 24.13.2 + '@types/node': 24.13.3 chalk: 5.6.2 vue: 3.5.35(typescript@7.0.2) transitivePeerDependencies: @@ -20778,7 +20574,7 @@ snapshots: read-pkg: 10.0.0 registry-auth-token: 5.0.2 semantic-release: 25.0.5(supports-color@10.2.2)(typescript@7.0.2) - semver: 7.7.4 + semver: 7.8.5 tempy: 3.1.0 '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.5(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)': @@ -21602,7 +21398,7 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/better-sqlite3@7.6.13': dependencies: @@ -21611,11 +21407,11 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/chai@5.2.2': dependencies: @@ -21626,18 +21422,18 @@ snapshots: '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.5 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/connect@3.4.38': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/cookies@0.9.2': dependencies: '@types/connect': 3.4.38 '@types/express': 4.17.25 '@types/keygrip': 1.0.6 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/crypto-js@4.2.2': {} @@ -21776,13 +21572,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/ssh2': 1.15.1 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/ssh2': 1.15.1 '@types/estree-jsx@1.0.5': @@ -21793,7 +21589,7 @@ snapshots: '@types/express-serve-static-core@4.19.5': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/qs': 6.9.16 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -21825,7 +21621,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/istanbul-lib-coverage@2.0.6': {} @@ -21855,7 +21651,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 form-data: 4.0.6 '@types/node-unifi@2.5.1(patch_hash=5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5)': @@ -21868,10 +21664,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@24.13.2': - dependencies: - undici-types: 7.18.2 - '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -21933,14 +21725,14 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/semver@7.5.8': {} '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/serve-index@1.9.4': dependencies: @@ -21949,7 +21741,7 @@ snapshots: '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/send': 0.17.4 '@types/set-cookie-parser@2.4.10': @@ -21959,15 +21751,15 @@ snapshots: '@types/sockjs@0.3.36': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/ssh2-streams@0.1.12': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/ssh2@0.5.52': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/ssh2-streams': 0.1.12 '@types/ssh2@1.15.1': @@ -21979,7 +21771,7 @@ snapshots: '@types/stream-buffers@3.0.7': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/triple-beam@1.3.5': {} @@ -22000,11 +21792,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/xml2js@0.4.14': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/yargs-parser@21.0.3': {} @@ -22098,11 +21890,6 @@ snapshots: mux.js: 7.1.0 video.js: 8.23.9 - '@videojs/vhs-utils@4.1.1': - dependencies: - '@babel/runtime': 7.29.7 - global: 4.4.0 - '@videojs/vhs-utils@4.1.2': dependencies: '@babel/runtime': 7.29.7 @@ -22161,7 +21948,6 @@ snapshots: optionalDependencies: msw: 2.14.6(@types/node@24.13.3)(typescript@7.0.2) vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0) - optional: true '@vitest/pretty-format@4.1.10': dependencies: @@ -22421,7 +22207,7 @@ snapshots: aes-decrypter@4.0.2: dependencies: '@babel/runtime': 7.29.7 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 global: 4.4.0 pkcs7: 1.0.4 @@ -22851,6 +22637,8 @@ snapshots: boolbase@1.0.0: {} + boolbase@2.0.0: {} + bottleneck@2.19.5: {} boxen@6.2.1: @@ -23270,7 +23058,7 @@ snapshots: conventional-commits-filter: 5.0.0 handlebars: 4.7.8 meow: 13.2.0 - semver: 7.7.4 + semver: 7.8.5 conventional-commits-filter@5.0.0: {} @@ -23419,12 +23207,12 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.16) postcss-modules-values: 4.0.0(postcss@8.5.16) postcss-value-parser: 4.2.0 - semver: 7.7.4 + semver: 7.8.5 optionalDependencies: '@rspack/core': 1.7.11 webpack: 5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3) - css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)): + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.32.0)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 6.1.2(postcss@8.5.16) @@ -23436,7 +23224,7 @@ snapshots: optionalDependencies: clean-css: 5.3.3 csso: 5.0.5 - esbuild: 0.28.0 + esbuild: 0.28.1 lightningcss: 1.32.0 css-prefers-color-scheme@10.0.0(postcss@8.5.16): @@ -23459,6 +23247,14 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + css-selector-parser@3.3.0: {} css-tree@2.2.1: @@ -23478,6 +23274,8 @@ snapshots: css-what@6.2.2: {} + css-what@8.0.0: {} + cssdb@8.9.0: {} cssesc@3.0.0: {} @@ -23541,6 +23339,8 @@ snapshots: dependencies: css-tree: 2.2.1 + cssom@0.5.0: {} + csstype@3.2.3: {} cva@1.0.0-beta.4(typescript@7.0.2): @@ -23876,17 +23676,6 @@ snapshots: transitivePeerDependencies: - supports-color - dockerode@5.0.0(supports-color@10.2.2): - dependencies: - '@balena/dockerignore': 1.0.2 - '@grpc/grpc-js': 1.12.5 - '@grpc/proto-loader': 0.7.13 - docker-modem: 5.0.7(supports-color@10.2.2) - protobufjs: 7.4.0 - tar-fs: 2.1.4 - transitivePeerDependencies: - - supports-color - dockerode@5.0.1(supports-color@10.2.2): dependencies: '@balena/dockerignore': 1.0.2 @@ -23925,10 +23714,18 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + dom-walk@0.1.2: {} domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -23937,10 +23734,18 @@ snapshots: dependencies: domelementtype: 2.3.0 + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -23953,6 +23758,12 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -24178,35 +23989,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -24310,7 +24092,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 require-like: 0.1.2 event-target-shim@5.0.1: {} @@ -24376,14 +24158,14 @@ snapshots: cross-spawn: 7.0.6 figures: 6.1.0 get-stream: 9.0.1 - human-signals: 8.0.0 + human-signals: 8.0.1 is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 pretty-ms: 9.3.0 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.1.1 + yoctocolors: 2.2.0 expand-template@2.0.3: {} @@ -24524,14 +24306,6 @@ snapshots: path-expression-matcher: 1.5.0 xml-naming: 0.1.0 - fast-xml-parser@5.8.0: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.3.0 - xml-naming: 0.1.0 - fast-xml-parser@5.9.3: dependencies: '@nodable/entities': 2.2.0 @@ -24556,7 +24330,7 @@ snapshots: process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.1.0 - semver: 7.7.4 + semver: 7.8.5 toad-cache: 3.7.0 fastq@1.20.1: @@ -24571,9 +24345,9 @@ snapshots: dependencies: websocket-driver: 0.7.5 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fecha@4.2.3: {} @@ -25210,6 +24984,8 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + html-minifier-terser@6.1.0: dependencies: camel-case: 4.1.2 @@ -25342,8 +25118,6 @@ snapshots: human-signals@5.0.0: {} - human-signals@8.0.0: {} - human-signals@8.0.1: {} hyperdyperid@1.2.0: {} @@ -25382,8 +25156,6 @@ snapshots: immediate@3.0.6: {} - immer@11.1.4: {} - immer@11.1.9: {} immutable@5.1.5: {} @@ -25593,14 +25365,6 @@ snapshots: isobject@3.0.1: {} - isomorphic-dompurify@3.18.0(@noble/hashes@2.0.1): - dependencies: - dompurify: 3.4.11 - jsdom: 29.1.1(@noble/hashes@2.0.1) - transitivePeerDependencies: - - '@noble/hashes' - - canvas - isomorphic-ws@5.0.0(ws@8.21.0): dependencies: ws: 8.21.0 @@ -25641,7 +25405,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.13.2 + '@types/node': 24.13.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -25655,7 +25419,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -25887,6 +25651,14 @@ snapshots: lines-and-columns@1.2.4: {} + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + linkifyjs@4.3.3: {} load-json-file@4.0.0: @@ -25985,7 +25757,7 @@ snapshots: m3u8-parser@7.2.0: dependencies: '@babel/runtime': 7.29.7 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 global: 4.4.0 magic-string@0.30.21: @@ -26000,7 +25772,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 mantine-datatable@9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: @@ -26852,7 +26624,7 @@ snapshots: node-abi@3.67.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-addon-api@7.1.1: {} @@ -26885,13 +26657,13 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.2 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -26921,6 +26693,10 @@ snapshots: dependencies: boolbase: 1.0.0 + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + null-loader@4.0.1(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 @@ -27125,7 +26901,7 @@ snapshots: got: 12.6.1 registry-auth-token: 5.0.2 registry-url: 6.0.1 - semver: 7.7.4 + semver: 7.8.5 package-manager-detector@1.6.0: {} @@ -27294,8 +27070,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} pify@3.0.0: {} @@ -27513,7 +27287,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@7.0.2) jiti: 1.21.7 postcss: 8.5.16 - semver: 7.7.4 + semver: 7.8.5 webpack: 5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3) transitivePeerDependencies: - typescript @@ -27859,7 +27633,7 @@ snapshots: '@posthog/core': 1.40.2 '@posthog/types': 1.393.0 core-js: 3.49.0 - dompurify: 3.4.11 + dompurify: 3.4.13 fflate: 0.4.8 preact: 10.29.7(preact-render-to-string@6.5.11) query-selector-shadow-dom: 1.0.1 @@ -28046,7 +27820,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.13.2 + '@types/node': 24.13.3 long: 5.3.2 proxmox-api@1.1.1: @@ -28322,14 +28096,14 @@ snapshots: dependencies: find-up-simple: 1.0.1 read-pkg: 10.0.0 - type-fest: 5.6.0 + type-fest: 5.7.0 read-pkg@10.0.0: dependencies: '@types/normalize-package-data': 2.4.4 normalize-package-data: 8.0.0 parse-json: 8.3.0 - type-fest: 5.6.0 + type-fest: 5.7.0 unicorn-magic: 0.3.0 read-pkg@9.0.1: @@ -28624,8 +28398,6 @@ snapshots: requires-port@1.0.0: {} - reselect@5.1.1: {} - reselect@5.2.0: {} reserved-identifiers@1.2.0: {} @@ -28819,7 +28591,7 @@ snapshots: p-reduce: 3.0.0 read-package-up: 12.0.0 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 signale: 1.4.0 yargs: 18.0.0 transitivePeerDependencies: @@ -28828,7 +28600,7 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 semver-parser@4.2.1: {} @@ -28838,8 +28610,7 @@ snapshots: semver@7.7.4: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} send@0.19.2(supports-color@10.2.2): dependencies: @@ -29289,8 +29060,6 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@2.3.0: {} - strnum@2.4.1: dependencies: anynum: 1.0.1 @@ -29402,16 +29171,6 @@ snapshots: pump: 3.0.2 tar-stream: 2.2.0 - tar-fs@3.1.2: - dependencies: - pump: 3.0.2 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 4.1.2 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-buffer - tar-fs@3.1.3: dependencies: pump: 3.0.2 @@ -29445,25 +29204,6 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - terser-webpack-plugin@5.6.0(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.0)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.46.1 - webpack: 5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3) - optionalDependencies: - '@swc/core': 1.15.3 - '@swc/html': 1.15.40 - clean-css: 5.3.3 - cssnano: 6.1.2(postcss@8.5.16) - csso: 5.0.5 - esbuild: 0.28.0 - html-minifier-terser: 7.2.0 - lightningcss: 1.32.0 - postcss: 8.5.16 - uglify-js: 3.19.3 - terser-webpack-plugin@5.6.0(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)(webpack@5.107.1(@swc/core@1.15.3)(@swc/html@1.15.40)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.32.0)(postcss@8.5.16)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -29499,12 +29239,12 @@ snapshots: byline: 5.0.0 debug: 4.4.3(supports-color@10.2.2) docker-compose: 1.4.2 - dockerode: 5.0.0(supports-color@10.2.2) + dockerode: 5.0.1(supports-color@10.2.2) get-port: 5.1.1 proper-lockfile: 4.1.2 properties-reader: 3.0.1(supports-color@10.2.2) ssh-remote-port-forward: 1.0.4 - tar-fs: 3.1.2 + tar-fs: 3.1.3 tmp: 0.2.7 undici: 8.6.0 transitivePeerDependencies: @@ -29556,8 +29296,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -29718,10 +29458,6 @@ snapshots: type-fest@4.41.0: {} - type-fest@5.6.0: - dependencies: - tagged-tag: 1.0.0 - type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -29769,6 +29505,8 @@ snapshots: uglify-js@3.19.3: optional: true + uhyphen@0.2.0: {} + uint8array-extras@1.5.0: {} uncrypto@0.1.3: {} @@ -29901,7 +29639,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.4 + semver: 7.8.5 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -29977,7 +29715,7 @@ snapshots: validate-peer-dependencies@2.2.0: dependencies: resolve-package-path: 4.0.3 - semver: 7.7.4 + semver: 7.8.5 value-equal@1.0.1: {} @@ -30086,7 +29824,6 @@ snapshots: terser: 5.46.1 tsx: 4.21.0 yaml: 2.9.0 - optional: true vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)): dependencies: @@ -30102,7 +29839,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 @@ -30133,7 +29870,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 @@ -30149,7 +29886,6 @@ snapshots: jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: - msw - optional: true vue-component-type-helpers@3.3.4: {} @@ -30516,8 +30252,6 @@ snapshots: yoctocolors-cjs@2.1.3: {} - yoctocolors@2.1.1: {} - yoctocolors@2.2.0: {} zip-stream@6.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16a82efb0..d6e554ed6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -131,7 +131,6 @@ catalog: glob: ^13.0.6 ical.js: ^2.2.1 ioredis: 5.11.1 - isomorphic-dompurify: ^3.18.0 jotai: ^2.20.1 jsdom: ^29.1.1 json5: ^2.2.3 @@ -190,6 +189,8 @@ catalog: zod: ^4.4.3 zod-form-data: ^3.0.2 zod-validation-error: ^5.0.0 + linkedom: ^0.18.13 + dompurify: ^3.4.13 catalogMode: strict nodeLinker: hoisted diff --git a/scripts/benchmarks/board-static-graph.mts b/scripts/benchmarks/board-static-graph.mts index 8c7dda589..52a177fa6 100644 --- a/scripts/benchmarks/board-static-graph.mts +++ b/scripts/benchmarks/board-static-graph.mts @@ -142,8 +142,6 @@ export const collectBoardStaticGraph = (repositoryRoot: string) => { const parents = new Map(); const bareWidgetImports: Array<{ importer: string; specifier: string }> = []; const forbiddenModules = new Set([ - path.join(root, "packages/widgets/index.ts"), - path.join(root, "packages/widgets/src/index.tsx"), path.join(root, "packages/spotlight/src/component.ts"), path.join(root, "packages/spotlight/src/components/spotlight.tsx"), path.join(root, "packages/spotlight/src/spotlight-store.ts"), diff --git a/scripts/run.sh b/scripts/run.sh index 83eff588e..9f26b2406 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -6,6 +6,7 @@ mkdir -p /appdata/redis mkdir -p /appdata/trusted-certificates # Run migrations +export NODE_OPTIONS="--max-old-space-size=${HOMARR_MAX_OLD_SPACE_SIZE:-512} ${NODE_OPTIONS}" if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then echo "DB migrations are disabled, skipping" else