Next loads the module graph for all 74 app routes when the server starts. Measured here: 1,114 files and 24 MiB of source resident before the first request, including one shared 16 MiB subtree behind the API routes that reaches every integration client. A dashboard serves a handful of routes per session. `experimental.preloadEntriesOnStart: false` is Next's documented switch for this. An earlier round of this work recorded route preloading as "framework behaviour, not something this codebase sets" - that was wrong, and the findings doc is corrected. Deterministic per-stage measurements, identical across both runs on each side: stage source loaded files heapUsed boot idle 24.0 -> 10.6 MiB 1114 -> 706 70.7 -> 44.8 (-37%) post-login 27.9 -> 19.3 MiB 1215 -> 884 123.8 -> 109.8 board loaded 29.0 -> 21.7 MiB 1323 -> 1006 (run-to-run noise) multi-tab 29.2 -> 22.9 MiB 1325 -> 1015 139.7 -> 127.8 post-soak 29.2 -> 22.9 MiB 1325 -> 1015 142.5 -> 127.4 The important part is the last two rows: 310 files and 6.3 MiB of source stay unloaded even after a full multi-tab session, so this is not merely deferred cost - routes the session never visits are never paid for at all. Live heap is lower at every stage, by 26 MiB at idle and 12-15 MiB under load and at rest. Peak node RSS does not move (350.3 -> 346.1, spreads overlap). V8 sizes its heap against available headroom, so a smaller live set does not shrink peak RSS; what improves is idle footprint and how much room is left before the heap cap. Median board load is +3% with overlapping spreads, so the first-hit cost this trades for did not show at this workload. Full E2E suite passes 8/8 against the built image.
163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
// Importing env files here to validate on build
|
|
import path from "node:path";
|
|
|
|
import "@homarr/auth/env";
|
|
import "@homarr/core/infrastructure/db/env";
|
|
import "@homarr/common/env";
|
|
import "@homarr/core/infrastructure/logs/env";
|
|
import "@homarr/docker/env";
|
|
|
|
import type { NextConfig } from "next";
|
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
|
|
// Package path does not work... so we need to use relative path
|
|
const withNextIntl = createNextIntlPlugin({
|
|
requestConfig: "../../packages/translation/src/request.ts",
|
|
});
|
|
|
|
/**
|
|
* Profiling build. React's production bundle strips the hooks React DevTools needs,
|
|
* which is why the Profiler tab reports "Profiling not supported"; the profiling
|
|
* variant keeps them. Source maps are emitted alongside so heap snapshots and flame
|
|
* charts show real component names instead of minified ones like `y`.
|
|
*
|
|
* Build with:
|
|
* docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance
|
|
*
|
|
* `reactProductionProfiling` in this config is only read by the webpack path — under
|
|
* Turbopack it comes from `next build --profile`, which apps/nextjs/package.json
|
|
* passes when HOMARR_PROFILING=true. Both are set so either bundler works.
|
|
*
|
|
* Off by default: the profiling variant is slower and source maps are large, so this
|
|
* must never be what ships to users.
|
|
*/
|
|
const isProfilingBuild = process.env.HOMARR_PROFILING === "true";
|
|
|
|
const nextConfig: NextConfig = {
|
|
reactProductionProfiling: isProfilingBuild,
|
|
productionBrowserSourceMaps: isProfilingBuild,
|
|
// Next previews otherwise create agent instruction files in the application
|
|
// directory during development.
|
|
agentRules: false,
|
|
env: {
|
|
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
|
},
|
|
output: "standalone",
|
|
reactStrictMode: true,
|
|
// 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
|
|
*
|
|
* Everything else here is a server-only dependency that is cheaper left as a
|
|
* runtime require than inlined into the server bundle. Bundling pulls a package's
|
|
* whole transitive graph into a chunk that is loaded and compiled at boot even
|
|
* when the code path never runs — measurably so for the DB drivers: only
|
|
* better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of
|
|
* resident memory at idle.
|
|
*/
|
|
serverExternalPackages: [
|
|
"dockerode",
|
|
"better-sqlite3",
|
|
"mysql2",
|
|
"pg",
|
|
"winston",
|
|
"drizzle-orm",
|
|
"ical.js",
|
|
"jszip",
|
|
"ldapts",
|
|
"node-unifi",
|
|
"@kubernetes/client-node",
|
|
"linkedom",
|
|
// Only reachable through /api/mcp/[transport].
|
|
"@modelcontextprotocol/sdk",
|
|
// Bundling inlines a copy per chunk: a server heap snapshot found this package's
|
|
// command table in 10 chunks and resident 6 times over (2.1 MiB of duplication).
|
|
// Safe to externalise because ioredis is already external and owns this as a
|
|
// direct dependency, so it is always traced alongside it. Bundle hygiene — it did
|
|
// not move measured memory (idle 89.7 -> 89.4 MiB, inside run-to-run noise).
|
|
// got and @octokit/request-error duplicate similarly but are only transitive
|
|
// deps here, so externalising them risks a runtime require that was never traced,
|
|
// for no measured gain.
|
|
"@ioredis/commands",
|
|
],
|
|
experimental: {
|
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
|
turbopackFileSystemCacheForBuild: true,
|
|
useTypeScriptCli: true,
|
|
/**
|
|
* Next otherwise loads the module graph for every route at startup. Measured here: 74 route
|
|
* bundles and 1,114 files, 24 MiB of source, resident before the first request — and one
|
|
* shared 16 MiB subtree behind the API routes that reaches every integration. A dashboard
|
|
* serves a handful of routes per session, so preloading the rest is pure cost.
|
|
*
|
|
* The trade is that the first request to a route pays its module load instead of the boot
|
|
* paying for all of them. That suits a long-lived self-hosted server, where boot happens
|
|
* once and routes warm up as they are used.
|
|
*/
|
|
preloadEntriesOnStart: false,
|
|
},
|
|
turbopack: {
|
|
root: path.resolve(import.meta.dirname, "../.."),
|
|
resolveAlias:
|
|
process.env.NODE_ENV === "development"
|
|
? {
|
|
"@homarr/tasks": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
|
|
"@homarr/websocket": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
|
|
}
|
|
: {},
|
|
},
|
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
|
images: {
|
|
localPatterns: [
|
|
{
|
|
pathname: "/**",
|
|
search: "",
|
|
},
|
|
],
|
|
},
|
|
// skipcq: JS-0116
|
|
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: "/sw.js",
|
|
headers: [
|
|
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
|
|
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
|
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
|
|
],
|
|
},
|
|
{
|
|
source: "/(.*)", // Apply CSP to all routes
|
|
headers: [
|
|
{
|
|
key: "Content-Security-Policy",
|
|
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
|
|
value: `
|
|
default-src 'self';
|
|
script-src * 'unsafe-inline' 'unsafe-eval';
|
|
worker-src * blob:;
|
|
base-uri 'self';
|
|
connect-src *;
|
|
style-src * 'unsafe-inline';
|
|
frame-ancestors *;
|
|
frame-src *;
|
|
form-action 'self';
|
|
img-src * data:;
|
|
font-src * data:;
|
|
media-src * data: blob:;
|
|
`
|
|
.replace(/\s{2,}/g, " ")
|
|
.trim(),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export default withNextIntl(nextConfig);
|