From e036cd86d840c575872897d08448f6a6362e1819 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 8 Aug 2026 08:58:54 +0200 Subject: [PATCH] perf: move dependencies to build-time static JSON, add build benchmark - Move getDependenciesAsync filesystem scan to scripts/dump-dependencies.mts (runs at build time, outputs static-data/dependencies.json) - About page now imports dependencies.json like other static data (eliminates runtime glob of all package.json files) - Delete dead getDependenciesAsync + parseDependencyCatalogsAsync from package-reader.ts (only getPackageVersion remains) - Add scripts/benchmarks/build-compare.sh for dev vs branch comparison --- .../src/app/[locale]/manage/about/page.tsx | 50 +++--- apps/nextjs/src/versions/package-reader.ts | 60 ------- scripts/benchmarks/build-compare.sh | 77 +++++++++ scripts/dump-dependencies.mts | 49 ++++++ static-data/dependencies.json | 157 ++++++++++++++++++ 5 files changed, 304 insertions(+), 89 deletions(-) create mode 100755 scripts/benchmarks/build-compare.sh create mode 100644 scripts/dump-dependencies.mts create mode 100644 static-data/dependencies.json diff --git a/apps/nextjs/src/app/[locale]/manage/about/page.tsx b/apps/nextjs/src/app/[locale]/manage/about/page.tsx index 9577d39a0..21143678e 100644 --- a/apps/nextjs/src/app/[locale]/manage/about/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/about/page.tsx @@ -40,8 +40,9 @@ import { getScopedI18n } from "@homarr/translation/server"; import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { createMetaTitle } from "~/metadata"; -import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader"; +import { getPackageVersion } from "~/versions/package-reader"; import githubContributors from "@static-data/contributors.json"; +import dependencies from "@static-data/dependencies.json"; import openCollectiveContributors from "@static-data/opencollective-contributors.json"; import crowdinContributors from "@static-data/translators.json"; import classes from "./about.module.css"; @@ -57,7 +58,6 @@ export async function generateMetadata() { export default async function AboutPage() { const t = await getScopedI18n("management.page.about"); const version = getPackageVersion(); - const dependencies = await getDependenciesAsync(); return (
@@ -159,34 +159,26 @@ export default async function AboutPage() { - {Object.entries(dependencies) - .filter(([, value]) => !value.includes("workspace:")) - .toSorted(([key1], [key2]) => key1.localeCompare(key2)) - .map(([name, version]) => ( - - - - - - + {Object.entries(dependencies).map(([name, version]) => ( + + + + + + - - - {name} - - - v{version.replace("^", "").replace("~", "")} - - - - - - ))} + + + {name} + + + v{version.replace("^", "").replace("~", "")} + + + + + + ))} diff --git a/apps/nextjs/src/versions/package-reader.ts b/apps/nextjs/src/versions/package-reader.ts index bf1cd6405..86177bd62 100644 --- a/apps/nextjs/src/versions/package-reader.ts +++ b/apps/nextjs/src/versions/package-reader.ts @@ -1,63 +1,3 @@ -import fsPromises from "fs/promises"; -import path from "path"; -import { fileURLToPath } from "url"; -import { glob } from "glob"; -import { parse as parseYaml } from "yaml"; -import z from "zod"; - import packageJson from "../../../../package.json"; export const getPackageVersion = () => packageJson.version; -export const getDependenciesAsync = async (): Promise => { - const pathNames = await glob("**/package.json", { - ignore: "**/node_modules/**", - cwd: "../../", - absolute: true, - }); - const packageContents = await Promise.all(pathNames.map(async (path) => await fsPromises.readFile(path, "utf-8"))); - const packageDependencies = packageContents - .map((packageContent) => (JSON.parse(packageContent) as PackageJson).dependencies) - .filter((dependencies) => dependencies !== undefined); - - const catalog = await parseDependencyCatalogsAsync(); - - let dependencies = {}; - for (const dependenciesOfPackage of packageDependencies) { - const resolvedDependencies = Object.entries(dependenciesOfPackage).map(([name, version]) => { - const catalogVersion = catalog.get(name); - return [name, catalogVersion ?? version] as const; - }); - dependencies = { ...dependencies, ...Object.fromEntries(resolvedDependencies) }; - } - return dependencies; -}; - -type DependencyCatalog = Map; -const parseDependencyCatalogsAsync = async (): Promise => { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"); - - try { - const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8"); - const workspaceConfig: unknown = parseYaml(workspaceConfigContent); - const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig); - return new Map(Object.entries(parseResult.catalog ?? {})); - } catch (error) { - // The workspace config is absent in Docker (only the runner stage is shipped), - // so a missing file is expected. Surface any other failure (e.g. malformed YAML). - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return new Map(); - } - - throw error; - } -}; - -const workspaceConfigCatalogSchema = z.object({ - catalog: z.record(z.string(), z.string()).optional(), -}); - -export type PackageJsonDependencies = Record; -interface PackageJson { - dependencies: PackageJsonDependencies | undefined; -} diff --git a/scripts/benchmarks/build-compare.sh b/scripts/benchmarks/build-compare.sh new file mode 100755 index 000000000..eb7f76da2 --- /dev/null +++ b/scripts/benchmarks/build-compare.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Compares Next.js build output between origin/dev and the current branch. +# Runs two full builds — expect ~5-10 minutes total. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +NEXT_DIR="$REPO_ROOT/apps/nextjs" +OUT_DIR="$REPO_ROOT/.bench" +mkdir -p "$OUT_DIR" + +capture_metrics() { + local label="$1" out="$OUT_DIR/$label" + mkdir -p "$out" + + echo "=== Building $label ===" + cd "$REPO_ROOT" + + local start_ns + start_ns=$(date +%s) + pnpm turbo build --filter=@homarr/nextjs 2>&1 | tee "$out/build.log" + local end_ns + end_ns=$(date +%s) + echo $((end_ns - start_ns)) > "$out/build_time_s" + + # .next size + du -sh "$NEXT_DIR/.next" 2>/dev/null | cut -f1 > "$out/next_size" + + # Standalone size + if [ -d "$NEXT_DIR/.next/standalone" ]; then + du -sh "$NEXT_DIR/.next/standalone" | cut -f1 > "$out/standalone_size" + else + echo "n/a" > "$out/standalone_size" + fi + + # Prerendered routes + if [ -d "$NEXT_DIR/.next/server/app" ]; then + find "$NEXT_DIR/.next/server/app" -name '*.html' | wc -l | tr -d ' ' > "$out/prerendered_count" + else + echo "0" > "$out/prerendered_count" + fi + + # Route summary from build output + grep -E '(○|●|λ|ƒ|◐)\s' "$out/build.log" > "$out/routes.txt" 2>/dev/null || true + grep -c '^○' "$out/routes.txt" 2>/dev/null > "$out/static_routes" || echo "0" > "$out/static_routes" + grep -c '^ƒ\|^λ\|^●\|^◐' "$out/routes.txt" 2>/dev/null > "$out/dynamic_routes" || echo "0" > "$out/dynamic_routes" +} + +echo "Stashing uncommitted changes..." +git stash --include-untracked -q 2>/dev/null || true + +echo "" +echo "=== Phase 1: Build origin/dev ===" +git checkout origin/dev --detach -q +capture_metrics "dev" + +echo "" +echo "=== Phase 2: Build $CURRENT_BRANCH ===" +git checkout "$CURRENT_BRANCH" -q +git stash pop -q 2>/dev/null || true +capture_metrics "branch" + +echo "" +echo "========================================" +echo " BUILD COMPARISON" +echo "========================================" +printf "%-25s %-15s %-15s\n" "Metric" "dev" "$CURRENT_BRANCH" +printf "%-25s %-15s %-15s\n" "-------------------------" "---------------" "---------------" +printf "%-25s %-15s %-15s\n" "Build time (s)" "$(cat "$OUT_DIR/dev/build_time_s")" "$(cat "$OUT_DIR/branch/build_time_s")" +printf "%-25s %-15s %-15s\n" ".next size" "$(cat "$OUT_DIR/dev/next_size")" "$(cat "$OUT_DIR/branch/next_size")" +printf "%-25s %-15s %-15s\n" "Standalone size" "$(cat "$OUT_DIR/dev/standalone_size")" "$(cat "$OUT_DIR/branch/standalone_size")" +printf "%-25s %-15s %-15s\n" "Prerendered HTML files" "$(cat "$OUT_DIR/dev/prerendered_count")" "$(cat "$OUT_DIR/branch/prerendered_count")" +printf "%-25s %-15s %-15s\n" "Static routes" "$(cat "$OUT_DIR/dev/static_routes")" "$(cat "$OUT_DIR/branch/static_routes")" +printf "%-25s %-15s %-15s\n" "Dynamic routes" "$(cat "$OUT_DIR/dev/dynamic_routes")" "$(cat "$OUT_DIR/branch/dynamic_routes")" +echo "========================================" +echo "" +echo "Detailed logs in $OUT_DIR/{dev,branch}/build.log" diff --git a/scripts/dump-dependencies.mts b/scripts/dump-dependencies.mts new file mode 100644 index 000000000..9248338da --- /dev/null +++ b/scripts/dump-dependencies.mts @@ -0,0 +1,49 @@ +import fsPromises from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; +import { glob } from "glob"; +import { parse as parseYaml } from "yaml"; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(currentDir, ".."); +const outPath = path.resolve(repoRoot, "static-data", "dependencies.json"); + +interface PackageJson { + dependencies?: Record; +} + +const parseCatalogAsync = async (): Promise> => { + try { + const raw = await fsPromises.readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf-8"); + const parsed = parseYaml(raw) as { catalog?: Record }; + return new Map(Object.entries(parsed.catalog ?? {})); + } catch { + return new Map(); + } +}; + +const paths = await glob("**/package.json", { + ignore: "**/node_modules/**", + cwd: repoRoot, + absolute: true, +}); + +const catalog = await parseCatalogAsync(); +const merged: Record = {}; + +for (const p of paths) { + const pkg = JSON.parse(await fsPromises.readFile(p, "utf-8")) as PackageJson; + if (!pkg.dependencies) continue; + for (const [name, version] of Object.entries(pkg.dependencies)) { + merged[name] = catalog.get(name) ?? version; + } +} + +const sorted = Object.fromEntries( + Object.entries(merged) + .filter(([, v]) => !v.includes("workspace:")) + .sort(([a], [b]) => a.localeCompare(b)), +); + +await fsPromises.writeFile(outPath, JSON.stringify(sorted, null, 2) + "\n"); +console.log(`Wrote ${Object.keys(sorted).length} dependencies to ${outPath}`); diff --git a/static-data/dependencies.json b/static-data/dependencies.json new file mode 100644 index 000000000..4be134912 --- /dev/null +++ b/static-data/dependencies.json @@ -0,0 +1,157 @@ +{ + "@auth/core": "^0.41.2", + "@auth/drizzle-adapter": "^1.11.2", + "@ctrl/deluge": "^8.0.0", + "@ctrl/qbittorrent": "^9.14.0", + "@ctrl/transmission": "^7.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@docusaurus/core": "^3.10.1", + "@docusaurus/faster": "^3.10.1", + "@docusaurus/plugin-content-docs": "^3.10.1", + "@docusaurus/plugin-sitemap": "^3.10.1", + "@docusaurus/preset-classic": "^3.10.1", + "@drizzle-team/brocli": "^0.12.0", + "@extractus/feed-extractor": "7.2.1", + "@gfazioli/mantine-onboarding-tour": "^4.0.6", + "@homarr/gridstack": "^1.12.0", + "@homarr/node-unifi": "^2.6.0", + "@immich/sdk": "^3.0.2", + "@jellyfin/sdk": "^0.13.0", + "@kubernetes/client-node": "^1.4.0", + "@mantine/charts": "^9.4.1", + "@mantine/colors-generator": "^9.4.1", + "@mantine/core": "^9.4.1", + "@mantine/dates": "^9.4.1", + "@mantine/dropzone": "^9.4.1", + "@mantine/form": "^9.4.1", + "@mantine/hooks": "^9.4.1", + "@mantine/modals": "^9.4.1", + "@mantine/notifications": "^9.4.1", + "@mantine/spotlight": "^9.4.1", + "@mantine/tiptap": "^9.4.1", + "@mdx-js/react": "^3.1.1", + "@modelcontextprotocol/sdk": "1.29.0", + "@nivo/line": "^0.99.0", + "@paralleldrive/cuid2": "^3.3.0", + "@scalar/api-reference-react": "^0.9.54", + "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2", + "@t3-oss/env-nextjs": "^0.13.11", + "@tabler/icons-react": "^3.44.0", + "@tailwindcss/postcss": "^4.3.2", + "@tanstack/react-devtools": "^0.10.8", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-query-next-experimental": "^5.101.2", + "@testcontainers/mysql": "^12.0.4", + "@testcontainers/postgresql": "^12.0.4", + "@tiptap/extension-color": "3.27.3", + "@tiptap/extension-highlight": "3.27.3", + "@tiptap/extension-image": "3.27.3", + "@tiptap/extension-link": "^3.27.3", + "@tiptap/extension-placeholder": "^3.27.3", + "@tiptap/extension-table": "3.27.3", + "@tiptap/extension-table-cell": "3.27.3", + "@tiptap/extension-table-header": "3.27.3", + "@tiptap/extension-table-row": "3.27.3", + "@tiptap/extension-task-item": "3.27.3", + "@tiptap/extension-task-list": "3.27.3", + "@tiptap/extension-text-align": "3.27.3", + "@tiptap/extension-text-style": "3.27.3", + "@tiptap/extension-underline": "3.27.3", + "@tiptap/react": "^3.27.3", + "@tiptap/starter-kit": "^3.27.3", + "@trpc/client": "^11.18.0", + "@trpc/next": "^11.18.0", + "@trpc/react-query": "^11.18.0", + "@trpc/server": "^11.18.0", + "@trpc/tanstack-react-query": "^11.18.0", + "@xterm/addon-canvas": "^0.7.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "^6.0.0", + "adm-zip": "0.5.18", + "babel-plugin-react-compiler": "^1.0.0", + "bcrypt": "^6.0.0", + "better-sqlite3": "^12.11.1", + "chroma-js": "^3.2.0", + "clsx": "^2.1.1", + "cookie": "2.0.1", + "cookies": "^0.9.1", + "croner": "10.0.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.21", + "deepmerge": "4.3.1", + "dns-caching": "^0.2.9", + "dockerode": "^5.0.1", + "docusaurus-plugin-image-zoom": "^3.0.1", + "dompurify": "^3.4.13", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "drizzle-zod": "^0.8.3", + "fast-xml-parser": "^5.9.3", + "fastify": "^5.9.0", + "flag-icons": "^7.5.0", + "glob": "^13.0.6", + "ical.js": "^2.2.1", + "ioredis": "5.11.1", + "jotai": "^2.20.1", + "jsonpath-plus": "^10.4.0", + "jszip": "^3.10.1", + "ldapts": "8.1.8", + "leader-line-new": "^1.1.9", + "linkedom": "^0.18.13", + "lodash.clonedeep": "^4.5.0", + "lucide-react": "^1.24.0", + "mantine-datatable": "^9.3.1", + "mantine-react-table": "npm:mantine-react-table-open@9.0.3", + "maria2": "^0.4.1", + "mcp-handler": "^1.1.0", + "mysql2": "3.22.6", + "next": "16.3.0-preview.9", + "next-auth": "5.0.0-beta.31", + "next-intl": "4.13.1", + "octokit": "^5.0.5", + "pg": "^8.22.0", + "postcss": "^8.5.16", + "postcss-preset-mantine": "^1.18.0", + "posthog-docusaurus": "^2.0.5", + "posthog-js": "^1.399.1", + "posthog-node": "^5.40.0", + "prism-react-renderer": "^2.4.1", + "prismjs": "^1.30.0", + "proxmox-api": "1.1.1", + "random-word-slugs": "^0.1.7", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-error-boundary": "^6.1.2", + "react-jsx-parser": "^2.4.1", + "react-markdown": "^10.1.0", + "react-quill-new": "^3.8.3", + "react-simple-code-editor": "^0.14.1", + "recharts": "^3.9.1", + "sass": "^1.101.0", + "semver-parser": "^4.2.1", + "sql.js": "^1.14.1", + "superjson": "2.2.6", + "svgson": "^5.3.1", + "swiper": "^14.0.5", + "tailwindcss": "^4.3.2", + "timezones-ical-library": "^2.2.1", + "trpc-to-mcp": "^1.3.2", + "trpc-to-openapi": "^3.3.0", + "tsdav": "^2.3.0", + "tsx": "4.20.4", + "undici": "7.28.0", + "use-deep-compare-effect": "^1.8.1", + "video.js": "^8.23.9", + "winston": "3.19.0", + "ws": "^8.21.0", + "xml2js": "^0.6.2", + "yaml": "2.9.0", + "zod": "^4.4.3", + "zod-form-data": "^3.0.2", + "zod-validation-error": "^5.0.0" +}