Files
homarr/apps/nextjs/next.config.ts
T
Thomas Camlong f707f64333 perf: externalise @ioredis/commands, and identify what actually holds the memory
Tested the hypothesis that cached base64 images were bloating TanStack Query and
Redis. They are not, measured on a loaded 4-tab session:

  base64 data-URLs, server heap   0.01 MiB (2 strings)
  base64 blobs, browser heap      0.01 MiB (7 strings)
  Redis total                     2.17 MB
  image bytes in Redis            none - image-proxy stores URL + headers only

What the server heap is actually made of (141 MiB used): 41.4 MiB is bundled
JavaScript source retained as strings, ~29% of the heap. The memory is the code, not
cached data — which is why every cache-tuning idea in the fix catalog measured at
zero, and why serverExternalPackages was the only lever that moved idle memory.

7.86 MiB of that is literal duplicate copies: the same module inlined into several
Turbopack chunks and retained once per chunk. @ioredis/commands' command table was in
10 chunks and resident 6 times (2.07 MiB wasted), so it is now external — ioredis is
already external and owns it as a direct dependency, so it stays traced.

Recorded as bundle hygiene, NOT a memory win: idle 89.7 -> 89.4 MiB is inside noise,
and board-loaded/peak deltas were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169-173 for identical code).

got and @octokit/request-error duplicate just as badly but were deliberately left
bundled: they are only transitive dependencies, and a check inside the image showed
got is not traced into node_modules, so externalising it would have turned a bundled
module into a require that resolves to nothing — a possible MODULE_NOT_FOUND for zero
measured gain.

analyze-heap-strings.mjs buckets a snapshot's strings by what they are, which is what
made "37 MiB of strings" actionable instead of just alarming.

Verified: 28/28 widgets render, auth control passes, zero module-not-found in the
container log, typecheck clean. pnpm test 1794/1795 with two different Testcontainers
flakes across runs (pi-hole, sabnzbd); both pass 18/18 in isolation.
2026-08-09 16:07:26 +02:00

152 lines
5.6 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,
},
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);