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
This commit is contained in:
Thomas Camlong committed 2026-08-08 08:58:54 +02:00
1 parent 6692acbd33
commit e036cd86d8
5 files changed
+304 -89

No files matched your search

@@ -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 (
<div>
@@ -159,34 +159,26 @@ export default async function AboutPage() {
</AccordionControl>
<AccordionPanel>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
component="a"
href={`https://www.npmjs.com/package/${name}`}
target="_blank"
>
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
{Object.entries(dependencies).map(([name, version]) => (
<UnstyledButton key={name} component="a" href={`https://www.npmjs.com/package/${name}`} target="_blank">
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
@@ -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<PackageJsonDependencies> => {
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<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
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<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
}
+77
View File
@@ -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"
+49
View File
@@ -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<string, string>;
}
const parseCatalogAsync = async (): Promise<Map<string, string>> => {
try {
const raw = await fsPromises.readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf-8");
const parsed = parseYaml(raw) as { catalog?: Record<string, string> };
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<string, string> = {};
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}`);
+157
View File
@@ -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"
}