From 6940aa603f7b37b409df32641d53746f1797ee95 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 3 Jun 2026 17:17:19 -0700 Subject: [PATCH] Reduce @hexclave/cli bundle size by ~154 MB (#1544) --- apps/dashboard/next.config.mjs | 4 + .../stack-cli/scripts/copy-runtime-assets.mjs | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 9349d7837..81c56535d 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -61,6 +61,10 @@ const nextConfig = { }, images: { + // Disable image optimization in standalone/RDE builds to avoid shipping + // the sharp native binary (~17 MB). The RDE runs locally so optimized + // images are not needed. + ...(process.env.NEXT_CONFIG_OUTPUT === "standalone" ? { unoptimized: true } : {}), remotePatterns: [ { protocol: 'https', diff --git a/packages/stack-cli/scripts/copy-runtime-assets.mjs b/packages/stack-cli/scripts/copy-runtime-assets.mjs index 0fe09851c..d3646858c 100644 --- a/packages/stack-cli/scripts/copy-runtime-assets.mjs +++ b/packages/stack-cli/scripts/copy-runtime-assets.mjs @@ -70,6 +70,94 @@ function copyDashboardHoistedDependencies(pnpmRoot, current = pnpmRoot) { } } +// Packages that are only needed at build time or are unnecessary in the +// standalone runtime. These are pulled in by file tracing (e.g. via jiti/next) +// but are never loaded during production server execution. +// sharp and its native bindings (@img/*) are excluded because the RDE +// standalone build sets images.unoptimized=true. +const EXCLUDED_RUNTIME_PACKAGES = new Set([ + "typescript", + "sharp", + "@img/sharp-libvips-linux-x64", + "@img/sharp-linux-x64", + "@img/colour", +]); + +function hoistPnpmNodeModules(pnpmDir) { + // The pnpm store keeps a shared `node_modules/` directory for hoisted + // packages that peer-dep symlinks resolve through. After we dereference all + // symlinks, these packages must also be available at the top-level + // `node_modules/` so that Node.js module resolution finds them. + const sharedNodeModules = join(pnpmDir, "node_modules"); + if (!existsSync(sharedNodeModules)) { + return; + } + const destNodeModules = dirname(pnpmDir); + for (const entry of readdirSync(sharedNodeModules, { withFileTypes: true })) { + const name = entry.name; + if (name.startsWith(".")) { + continue; + } + if (name.startsWith("@")) { + // Scoped package — iterate one level deeper + const scopeDir = join(sharedNodeModules, name); + for (const scopedEntry of readdirSync(scopeDir, { withFileTypes: true })) { + const fullName = join(name, scopedEntry.name); + if (EXCLUDED_RUNTIME_PACKAGES.has(fullName)) { + continue; + } + const dest = join(destNodeModules, fullName); + if (!existsSync(dest)) { + cpSync(join(scopeDir, scopedEntry.name), dest, { recursive: true, dereference: true }); + } + } + } else { + if (EXCLUDED_RUNTIME_PACKAGES.has(name)) { + continue; + } + const dest = join(destNodeModules, name); + if (!existsSync(dest)) { + cpSync(join(sharedNodeModules, name), dest, { recursive: true, dereference: true }); + } + } + } +} + +function removePnpmStore(nodeModulesDir) { + const pnpmDir = join(nodeModulesDir, ".pnpm"); + if (!existsSync(pnpmDir)) { + return; + } + hoistPnpmNodeModules(pnpmDir); + rmSync(pnpmDir, { recursive: true, force: true }); +} + +function removeExcludedPackages(nodeModulesDir) { + for (const pkg of EXCLUDED_RUNTIME_PACKAGES) { + const pkgPath = join(nodeModulesDir, pkg); + if (existsSync(pkgPath)) { + rmSync(pkgPath, { recursive: true, force: true }); + } + } +} + +function removeNftJsonFiles(dir) { + // .nft.json files are Next.js file-trace manifests used only during the build + // to determine which files to include in standalone output. They are not + // needed at runtime and add ~4 MB to the package. + if (!existsSync(dir)) { + return; + } + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + removeNftJsonFiles(path); + } else if (entry.isFile() && entry.name.endsWith(".nft.json")) { + rmSync(path); + } + } +} + function copyDashboardAssets() { assertExists( join(dashboardStandaloneSrc, "apps/dashboard/server.js"), @@ -88,6 +176,15 @@ function copyDashboardAssets() { } copyDashboardHoistedDependencies(join(dashboardStandaloneSrc, "node_modules/.pnpm")); + // Remove the .pnpm store from the output. After cpSync with dereference:true + // all symlinks are resolved to real files, so the .pnpm directory is entirely + // duplicate content (~113 MB). We first hoist any shared packages that only + // exist inside .pnpm/node_modules/ to the top-level node_modules/. + const dashboardNodeModules = join(dashboardDist, "node_modules"); + removePnpmStore(dashboardNodeModules); + removeExcludedPackages(dashboardNodeModules); + removeNftJsonFiles(join(dashboardDist, "apps/dashboard/.next")); + console.log(`Copied dashboard standalone runtime into ${dashboardDist}.`); }