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:
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;
|
||||
}
|
||||
Executable
+77
@@ -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"
|
||||
@@ -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}`);
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user