diff --git a/web/Dockerfile b/web/Dockerfile index d284efca8..1376dec74 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -34,7 +34,7 @@ COPY --from=packages /app/web/ . COPY . . ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN pnpm build +RUN pnpm build:docker # production stage diff --git a/web/next.config.js b/web/next.config.js index 00793bf26..6920a47fb 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -27,7 +27,10 @@ const nextConfig = { basePath, assetPrefix, webpack: (config, { dev, isServer }) => { - config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' })) + if (dev) { + config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' })) + } + return config }, productionBrowserSourceMaps: false, // enable browser source map generation during the production build diff --git a/web/package.json b/web/package.json index 6623e3197..4d978c107 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "scripts": { "dev": "cross-env NODE_OPTIONS='--inspect' next dev", "build": "next build", + "build:docker": "next build && node scripts/optimize-standalone.js", "start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js", "lint": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache", "lint-only-show-error": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --quiet", diff --git a/web/scripts/README.md b/web/scripts/README.md new file mode 100644 index 000000000..2c575a244 --- /dev/null +++ b/web/scripts/README.md @@ -0,0 +1,38 @@ +# Production Build Optimization Scripts + +## optimize-standalone.js + +This script removes unnecessary development dependencies from the Next.js standalone build output to reduce the production Docker image size. + +### What it does + +The script specifically targets and removes `jest-worker` packages that are bundled with Next.js but not needed in production. These packages are included because: + +1. Next.js includes jest-worker in its compiled dependencies +1. terser-webpack-plugin (used by Next.js for minification) depends on jest-worker +1. pnpm's dependency resolution creates symlinks to jest-worker in various locations + +### Usage + +The script is automatically run during Docker builds via the `build:docker` npm script: + +```bash +# Docker build (removes jest-worker after build) +pnpm build:docker +``` + +To run the optimization manually: + +```bash +node scripts/optimize-standalone.js +``` + +### What gets removed + +- `node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker` +- `node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker` (symlinks) +- `node_modules/.pnpm/jest-worker@*` (actual packages) + +### Impact + +Removing jest-worker saves approximately 36KB per instance from the production image. While this may seem small, it helps ensure production images only contain necessary runtime dependencies. diff --git a/web/scripts/optimize-standalone.js b/web/scripts/optimize-standalone.js new file mode 100644 index 000000000..f434a5dae --- /dev/null +++ b/web/scripts/optimize-standalone.js @@ -0,0 +1,149 @@ +/** + * Script to optimize Next.js standalone output for production + * Removes unnecessary files like jest-worker that are bundled with Next.js + */ + +const fs = require('fs'); +const path = require('path'); + +console.log('šŸ”§ Optimizing standalone output...'); + +const standaloneDir = path.join(__dirname, '..', '.next', 'standalone'); + +// Check if standalone directory exists +if (!fs.existsSync(standaloneDir)) { + console.error('āŒ Standalone directory not found. Please run "next build" first.'); + process.exit(1); +} + +// List of paths to remove (relative to standalone directory) +const pathsToRemove = [ + // Remove jest-worker from Next.js compiled dependencies + 'node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker', + // Remove jest-worker symlinks from terser-webpack-plugin + 'node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker', + // Remove actual jest-worker packages (directories only, not symlinks) + 'node_modules/.pnpm/jest-worker@*', +]; + +// Function to safely remove a path +function removePath(basePath, relativePath) { + const fullPath = path.join(basePath, relativePath); + + // Handle wildcard patterns + if (relativePath.includes('*')) { + const parts = relativePath.split('/'); + let currentPath = basePath; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.includes('*')) { + // Find matching directories + if (fs.existsSync(currentPath)) { + const entries = fs.readdirSync(currentPath); + + // replace '*' with '.*' + const regexPattern = part.replace(/\*/g, '.*'); + + const regex = new RegExp(`^${regexPattern}$`); + + for (const entry of entries) { + if (regex.test(entry)) { + const remainingPath = parts.slice(i + 1).join('/'); + const matchedPath = path.join(currentPath, entry, remainingPath); + + try { + // Use lstatSync to check if path exists (works for both files and symlinks) + const stats = fs.lstatSync(matchedPath); + + if (stats.isSymbolicLink()) { + // Remove symlink + fs.unlinkSync(matchedPath); + console.log(`āœ… Removed symlink: ${path.relative(basePath, matchedPath)}`); + } else { + // Remove directory/file + fs.rmSync(matchedPath, { recursive: true, force: true }); + console.log(`āœ… Removed: ${path.relative(basePath, matchedPath)}`); + } + } catch (error) { + // Silently ignore ENOENT (path not found) errors + if (error.code !== 'ENOENT') { + console.error(`āŒ Failed to remove ${matchedPath}: ${error.message}`); + } + } + } + } + } + return; + } else { + currentPath = path.join(currentPath, part); + } + } + } else { + // Direct path removal + if (fs.existsSync(fullPath)) { + try { + fs.rmSync(fullPath, { recursive: true, force: true }); + console.log(`āœ… Removed: ${relativePath}`); + } catch (error) { + console.error(`āŒ Failed to remove ${fullPath}: ${error.message}`); + } + } + } +} + +// Remove unnecessary paths +console.log('šŸ—‘ļø Removing unnecessary files...'); +for (const pathToRemove of pathsToRemove) { + removePath(standaloneDir, pathToRemove); +} + +// Calculate size reduction +console.log('\nšŸ“Š Optimization complete!'); + +// Optional: Display the size of remaining jest-related files (if any) +const checkForJest = (dir) => { + const jestFiles = []; + + function walk(currentPath) { + if (!fs.existsSync(currentPath)) return; + + try { + const entries = fs.readdirSync(currentPath); + for (const entry of entries) { + const fullPath = path.join(currentPath, entry); + + try { + const stat = fs.lstatSync(fullPath); // Use lstatSync to handle symlinks + + if (stat.isDirectory() && !stat.isSymbolicLink()) { + // Skip node_modules subdirectories to avoid deep traversal + if (entry === 'node_modules' && currentPath !== standaloneDir) { + continue; + } + walk(fullPath); + } else if (stat.isFile() && entry.includes('jest')) { + jestFiles.push(path.relative(standaloneDir, fullPath)); + } + } catch (err) { + // Skip files that can't be accessed + continue; + } + } + } catch (err) { + // Skip directories that can't be read + return; + } + } + + walk(dir); + return jestFiles; +}; + +const remainingJestFiles = checkForJest(standaloneDir); +if (remainingJestFiles.length > 0) { + console.log('\nāš ļø Warning: Some jest-related files still remain:'); + remainingJestFiles.forEach(file => console.log(` - ${file}`)); +} else { + console.log('\n✨ No jest-related files found in standalone output!'); +}