Skip to content

Commit 7cba1d5

Browse files
authored
Merge pull request #39 from Godsmiracle001/feat/issue-29-performance-optimization
feat: implement performance optimization and budget enforcement
2 parents c29814a + 61f720d commit 7cba1d5

18 files changed

Lines changed: 668 additions & 50 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Performance Budget
2+
3+
on:
4+
pull_request:
5+
branches: ["main", "master"]
6+
push:
7+
branches: ["main", "master"]
8+
9+
jobs:
10+
performance-budget:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Node
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: 20
20+
cache: npm
21+
22+
- name: Install
23+
run: npm ci
24+
25+
- name: Build with stats
26+
run: npm run build:analyze
27+
env:
28+
ANALYZE: "true"
29+
30+
- name: Check budgets
31+
run: npm run perf:budgets
32+
env:
33+
TOTAL_JS_BUDGET_KB: "650"
34+
MAX_CHUNK_BUDGET_KB: "220"

next.config.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,121 @@
11
import type { NextConfig } from "next";
22

3+
const isAnalyzeEnabled = process.env.ANALYZE === "true";
4+
35
const nextConfig: NextConfig = {
4-
/* config options here */
6+
experimental: {
7+
optimizePackageImports: [
8+
"lucide-react",
9+
"recharts",
10+
"framer-motion",
11+
"wagmi",
12+
"viem",
13+
],
14+
},
15+
images: {
16+
remotePatterns: [
17+
{
18+
protocol: "https",
19+
hostname: "images.unsplash.com",
20+
},
21+
],
22+
formats: ["image/avif", "image/webp"],
23+
},
24+
async headers() {
25+
return [
26+
{
27+
source: "/_next/static/:path*",
28+
headers: [
29+
{
30+
key: "Cache-Control",
31+
value: "public, max-age=31536000, immutable",
32+
},
33+
],
34+
},
35+
{
36+
source: "/:path*.(png|jpg|jpeg|gif|webp|avif|svg|ico)",
37+
headers: [
38+
{
39+
key: "Cache-Control",
40+
value: "public, max-age=604800, stale-while-revalidate=86400",
41+
},
42+
],
43+
},
44+
{
45+
source: "/sw.js",
46+
headers: [
47+
{
48+
key: "Cache-Control",
49+
value: "no-cache, no-store, must-revalidate",
50+
},
51+
],
52+
},
53+
];
54+
},
55+
webpack: (config, { isServer, webpack }) => {
56+
config.resolve = config.resolve ?? {};
57+
config.resolve.alias = {
58+
...(config.resolve.alias ?? {}),
59+
"@walletconnect/ethereum-provider": false,
60+
"@safe-global/safe-apps-sdk": false,
61+
"@safe-global/safe-apps-provider": false,
62+
"@base-org/account": false,
63+
"@gemini-wallet/core": false,
64+
"@react-native-async-storage/async-storage": false,
65+
porto: false,
66+
"porto/internal": false,
67+
};
68+
69+
if (!isServer && config.optimization?.splitChunks) {
70+
config.optimization.splitChunks = {
71+
...config.optimization.splitChunks,
72+
cacheGroups: {
73+
...(config.optimization.splitChunks.cacheGroups ?? {}),
74+
web3: {
75+
name: "web3-vendors",
76+
test: /[\\/]node_modules[\\/](wagmi|viem|ethers|@walletconnect|@metamask|@coinbase)[\\/]/,
77+
chunks: "all",
78+
priority: 35,
79+
},
80+
charts: {
81+
name: "chart-vendors",
82+
test: /[\\/]node_modules[\\/](recharts|d3-.*)[\\/]/,
83+
chunks: "all",
84+
priority: 25,
85+
},
86+
},
87+
};
88+
}
89+
90+
if (isAnalyzeEnabled && !isServer) {
91+
class BuildStatsPlugin {
92+
apply(compiler: any) {
93+
compiler.hooks.done.tap("BuildStatsPlugin", (stats: any) => {
94+
const fs = require("fs");
95+
const path = require("path");
96+
const outputPath = path.join(compiler.options.output.path ?? ".next", "build-stats.json");
97+
fs.writeFileSync(
98+
outputPath,
99+
JSON.stringify(
100+
stats.toJson({
101+
all: false,
102+
assets: true,
103+
chunks: true,
104+
chunkGroups: true,
105+
}),
106+
null,
107+
2
108+
)
109+
);
110+
});
111+
}
112+
}
113+
114+
config.plugins.push(new BuildStatsPlugin());
115+
}
116+
117+
return config;
118+
},
5119
};
6120

7121
export default nextConfig;

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
"private": true,
55
"scripts": {
66
"dev": "next dev",
7-
"typecheck": "tsc --noEmit --incremental false",
7+
"typecheck": "tsc --noEmit --incremental false",
88
"build": "npm run typecheck && next build",
9+
"build:analyze": "node scripts/run-analyze-build.mjs",
910
"start": "next start",
10-
"lint": "eslint . --max-warnings=0"
11+
"lint": "eslint . --max-warnings=0",
12+
"perf:budgets": "node scripts/check-performance-budgets.mjs",
13+
"perf:ci": "npm run build:analyze && npm run perf:budgets"
1114
},
1215
"dependencies": {
1316
"@coinbase/wallet-sdk": "^4.3.7",

public/sw.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const STATIC_CACHE = "propchain-static-v1";
2+
const RUNTIME_CACHE = "propchain-runtime-v1";
3+
const STATIC_ASSETS = ["/", "/favicon.ico"];
4+
5+
self.addEventListener("install", (event) => {
6+
event.waitUntil(
7+
caches.open(STATIC_CACHE).then((cache) => {
8+
return cache.addAll(STATIC_ASSETS);
9+
})
10+
);
11+
self.skipWaiting();
12+
});
13+
14+
self.addEventListener("activate", (event) => {
15+
event.waitUntil(
16+
caches.keys().then((keys) =>
17+
Promise.all(
18+
keys
19+
.filter((key) => key !== STATIC_CACHE && key !== RUNTIME_CACHE)
20+
.map((key) => caches.delete(key))
21+
)
22+
)
23+
);
24+
self.clients.claim();
25+
});
26+
27+
self.addEventListener("fetch", (event) => {
28+
if (event.request.method !== "GET") return;
29+
30+
const url = new URL(event.request.url);
31+
const isSameOrigin = url.origin === self.location.origin;
32+
const isStatic =
33+
url.pathname.startsWith("/_next/static") ||
34+
/\.(?:png|jpg|jpeg|webp|avif|svg|ico|css|js)$/.test(url.pathname);
35+
36+
if (isSameOrigin && isStatic) {
37+
event.respondWith(
38+
caches.match(event.request).then((cachedResponse) => {
39+
if (cachedResponse) return cachedResponse;
40+
return fetch(event.request).then((response) => {
41+
const responseClone = response.clone();
42+
caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, responseClone));
43+
return response;
44+
});
45+
})
46+
);
47+
return;
48+
}
49+
50+
if (isSameOrigin) {
51+
event.respondWith(
52+
caches.open(RUNTIME_CACHE).then(async (cache) => {
53+
const cached = await cache.match(event.request);
54+
const fetchPromise = fetch(event.request)
55+
.then((response) => {
56+
cache.put(event.request, response.clone());
57+
return response;
58+
})
59+
.catch(() => cached);
60+
return cached || fetchPromise;
61+
})
62+
);
63+
}
64+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from "fs";
2+
import path from "path";
3+
4+
const projectRoot = process.cwd();
5+
const nextDir = path.join(projectRoot, ".next");
6+
const chunksDir = path.join(nextDir, "static", "chunks");
7+
const buildManifestPath = path.join(nextDir, "build-manifest.json");
8+
9+
const totalBudgetKb = Number(process.env.TOTAL_JS_BUDGET_KB || 650);
10+
const chunkBudgetKb = Number(process.env.MAX_CHUNK_BUDGET_KB || 220);
11+
12+
if (!fs.existsSync(chunksDir)) {
13+
console.error("Missing .next/static/chunks. Run `next build` first.");
14+
process.exit(1);
15+
}
16+
17+
const allChunkFiles = [];
18+
const stack = [chunksDir];
19+
while (stack.length > 0) {
20+
const current = stack.pop();
21+
for (const item of fs.readdirSync(current, { withFileTypes: true })) {
22+
const fullPath = path.join(current, item.name);
23+
if (item.isDirectory()) {
24+
stack.push(fullPath);
25+
} else if (item.isFile() && item.name.endsWith(".js")) {
26+
allChunkFiles.push(fullPath);
27+
}
28+
}
29+
}
30+
31+
const manifest = fs.existsSync(buildManifestPath)
32+
? JSON.parse(fs.readFileSync(buildManifestPath, "utf8"))
33+
: null;
34+
35+
const initialFiles = new Set();
36+
if (manifest) {
37+
for (const file of [...(manifest.polyfillFiles || []), ...(manifest.rootMainFiles || [])]) {
38+
if (typeof file === "string" && file.endsWith(".js")) {
39+
initialFiles.add(file);
40+
}
41+
}
42+
for (const pageFiles of Object.values(manifest.pages || {})) {
43+
for (const file of pageFiles || []) {
44+
if (typeof file === "string" && file.endsWith(".js")) {
45+
initialFiles.add(file);
46+
}
47+
}
48+
}
49+
}
50+
51+
let allBytes = 0;
52+
for (const filePath of allChunkFiles) {
53+
const size = fs.statSync(filePath).size;
54+
allBytes += size;
55+
}
56+
57+
let initialBytes = 0;
58+
const offenders = [];
59+
for (const relativePath of initialFiles) {
60+
const filePath = path.join(nextDir, relativePath);
61+
if (!fs.existsSync(filePath)) continue;
62+
const size = fs.statSync(filePath).size;
63+
initialBytes += size;
64+
if (size > chunkBudgetKb * 1024) {
65+
offenders.push({
66+
file: relativePath,
67+
kb: (size / 1024).toFixed(1),
68+
});
69+
}
70+
}
71+
72+
const totalKb = initialBytes / 1024;
73+
console.log(`Initial JS size (build manifest): ${totalKb.toFixed(1)} KB`);
74+
console.log(`All chunk JS size: ${(allBytes / 1024).toFixed(1)} KB`);
75+
console.log(`Budget: ${totalBudgetKb} KB`);
76+
77+
let hasError = false;
78+
if (totalKb > totalBudgetKb) {
79+
console.error(`Total JS budget exceeded by ${(totalKb - totalBudgetKb).toFixed(1)} KB`);
80+
hasError = true;
81+
}
82+
83+
if (offenders.length > 0) {
84+
console.error(`Chunks above ${chunkBudgetKb} KB:`);
85+
for (const item of offenders) {
86+
console.error(`- ${item.file}: ${item.kb} KB`);
87+
}
88+
hasError = true;
89+
}
90+
91+
if (hasError) {
92+
process.exit(1);
93+
}
94+
95+
console.log("Performance budgets passed.");

scripts/run-analyze-build.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { spawnSync } from "child_process";
2+
3+
const result = spawnSync("npx", ["next", "build", "--webpack"], {
4+
stdio: "inherit",
5+
shell: true,
6+
env: {
7+
...process.env,
8+
ANALYZE: "true",
9+
},
10+
});
11+
12+
process.exit(result.status ?? 1);

0 commit comments

Comments
 (0)