@traceletdev/next 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/collect.js +287 -0
- package/index.js +26 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @traceletdev/next
|
|
2
|
+
|
|
3
|
+
Tracelet plugin for Next.js projects.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D @traceletdev/cli @traceletdev/next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
After installing, add a postbuild script to your `package.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postbuild": "node node_modules/@traceletdev/next/collect.js"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run manually after building:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run build
|
|
27
|
+
node node_modules/@traceletdev/next/collect.js
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This will generate `.tracelet/stats.json` which is used by `tracelet lint` to check performance budgets.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
The plugin reads Next.js build manifests (`.next/build-manifest.json`, `.next/app-build-manifest.json`, etc.) to:
|
|
35
|
+
|
|
36
|
+
- Detect all routes (including dynamic routes)
|
|
37
|
+
- Calculate gzip-compressed JavaScript sizes per route
|
|
38
|
+
- Identify third-party JavaScript bundles
|
|
39
|
+
- Generate statistics for tracelet linting
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Ensure you have a `tracelet.config.json` file in your project root. See the [main tracelet documentation](../../README.md) for configuration options.
|
|
44
|
+
|
package/collect.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const zlib = require('zlib');
|
|
6
|
+
|
|
7
|
+
function readJSON(p) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function gzipSize(p) {
|
|
16
|
+
try {
|
|
17
|
+
const content = fs.readFileSync(p);
|
|
18
|
+
const compressed = zlib.gzipSync(content, { level: 6 });
|
|
19
|
+
return compressed.length;
|
|
20
|
+
} catch {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function uniq(arr) {
|
|
26
|
+
return Array.from(new Set(arr));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sumFiles(projectRoot, relPaths) {
|
|
30
|
+
let total = 0;
|
|
31
|
+
let thirdParty = 0;
|
|
32
|
+
for (const rel of uniq(relPaths)) {
|
|
33
|
+
const full = path.join(projectRoot, '.next', rel);
|
|
34
|
+
const size = gzipSize(full);
|
|
35
|
+
total += size;
|
|
36
|
+
// Detect third-party: chunks in node_modules or vendor directories
|
|
37
|
+
// Next.js patterns:
|
|
38
|
+
// - framework-*.js (React, Next.js core)
|
|
39
|
+
// - main-app-*.js (Next.js app router)
|
|
40
|
+
// - webpack-*.js (webpack runtime)
|
|
41
|
+
// - Chunks with hash patterns often contain vendor code
|
|
42
|
+
if (
|
|
43
|
+
rel.includes('/node_modules/') ||
|
|
44
|
+
rel.includes('/webpack/') ||
|
|
45
|
+
rel.match(/^static\/chunks\/framework/) ||
|
|
46
|
+
rel.match(/^static\/chunks\/main-app/) ||
|
|
47
|
+
rel.match(/^static\/chunks\/webpack/) ||
|
|
48
|
+
rel.match(/^static\/chunks\/[0-9a-f]{16}\.js$/)
|
|
49
|
+
) {
|
|
50
|
+
thirdParty += size;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { total, thirdParty };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectRoutes(projectRoot) {
|
|
57
|
+
const buildManifest = readJSON(path.join(projectRoot, '.next', 'build-manifest.json')) || {
|
|
58
|
+
pages: {},
|
|
59
|
+
};
|
|
60
|
+
const prerenderManifest = readJSON(
|
|
61
|
+
path.join(projectRoot, '.next', 'prerender-manifest.json')
|
|
62
|
+
) || { routes: {} };
|
|
63
|
+
const appBuild = readJSON(path.join(projectRoot, '.next', 'app-build-manifest.json')) || {
|
|
64
|
+
pages: {},
|
|
65
|
+
rootMainFiles: [],
|
|
66
|
+
};
|
|
67
|
+
const appPathRoutes =
|
|
68
|
+
readJSON(path.join(projectRoot, '.next', 'app-path-routes-manifest.json')) || {};
|
|
69
|
+
|
|
70
|
+
const pages = buildManifest.pages || {};
|
|
71
|
+
const appPages = appBuild.pages || {};
|
|
72
|
+
|
|
73
|
+
// Build reverse map: route path -> manifest key (e.g., "/[locale]/money2020" -> "/(routes)/[locale]/money2020/page")
|
|
74
|
+
const routePathToManifestKey = {};
|
|
75
|
+
for (const [manifestKey, routePath] of Object.entries(appPathRoutes)) {
|
|
76
|
+
if (manifestKey.endsWith('/page')) {
|
|
77
|
+
routePathToManifestKey[routePath] = manifestKey;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeAppRoute(k) {
|
|
82
|
+
// Only count actual page routes; map '/page' → '/', '/foo/page' → '/foo'
|
|
83
|
+
if (!k.endsWith('/page')) return null;
|
|
84
|
+
let r = k.replace(/\/page$/, '') || '/';
|
|
85
|
+
// Drop segment groups like '(routes)'
|
|
86
|
+
r = r.replace(/\([^)]+\)\//g, '/');
|
|
87
|
+
return r;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isInternal(route) {
|
|
91
|
+
if (!route) return true;
|
|
92
|
+
if (route.startsWith('/_')) return true; // _app, _error, _not-found
|
|
93
|
+
if (route.includes('/api/')) return true; // API routes
|
|
94
|
+
if (route.endsWith('/route')) return true; // app dir route handlers
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isDynamicRoute(route) {
|
|
99
|
+
if (!route) return false;
|
|
100
|
+
// Skip dynamic route patterns like /[locale], /[...slug], /[id]
|
|
101
|
+
// These are templates, not concrete routes - concrete instances are already tracked
|
|
102
|
+
return /\[/.test(route);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isAssetRoute(route) {
|
|
106
|
+
// Filter top-level non-page assets commonly present
|
|
107
|
+
return route === '/favicon.ico' || route === '/robots.txt' || route === '/sitemap.xml';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cleanRoute(route) {
|
|
111
|
+
if (!route) return '/';
|
|
112
|
+
let r = route.replace(/\/+/, '/');
|
|
113
|
+
r = r.replace(/\/+/, '/'); // collapse again for edge cases
|
|
114
|
+
r = r.replace(/\/+/g, '/');
|
|
115
|
+
if (!r.startsWith('/')) r = '/' + r;
|
|
116
|
+
if (r.length > 1 && r.endsWith('/')) r = r.slice(0, -1);
|
|
117
|
+
return r;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const routeToFiles = new Map();
|
|
121
|
+
const dynamicPatternToFiles = new Map(); // Store chunks for dynamic route patterns
|
|
122
|
+
const routesFromDynamicPatterns = new Set(); // Track which routes were matched to dynamic patterns
|
|
123
|
+
|
|
124
|
+
// Helper: load nested manifest for routes not in main app-build-manifest
|
|
125
|
+
function loadNestedManifest(manifestKey) {
|
|
126
|
+
// Convert manifest key like "/(routes)/[locale]/money2020/page" to file path
|
|
127
|
+
const relPath = manifestKey.replace(/^\/|\/page$/g, '');
|
|
128
|
+
const manifestPath = path.join(
|
|
129
|
+
projectRoot,
|
|
130
|
+
'.next',
|
|
131
|
+
'server',
|
|
132
|
+
'app',
|
|
133
|
+
relPath,
|
|
134
|
+
'page',
|
|
135
|
+
'app-build-manifest.json'
|
|
136
|
+
);
|
|
137
|
+
const nested = readJSON(manifestPath);
|
|
138
|
+
return nested ? nested.pages || {} : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// App Router pages - collect both concrete routes and dynamic patterns
|
|
142
|
+
// Note: rootMainFiles are shared runtime chunks, NOT counted per route
|
|
143
|
+
for (const k of Object.keys(appPages)) {
|
|
144
|
+
let r = normalizeAppRoute(k);
|
|
145
|
+
if (!r || isInternal(r)) continue;
|
|
146
|
+
r = cleanRoute(r);
|
|
147
|
+
if (isAssetRoute(r)) continue;
|
|
148
|
+
// Only use route-specific chunks, exclude shared rootMainFiles
|
|
149
|
+
const files = appPages[k] || [];
|
|
150
|
+
if (isDynamicRoute(r)) {
|
|
151
|
+
// Store chunks for dynamic route patterns - concrete routes will use these
|
|
152
|
+
dynamicPatternToFiles.set(r, files);
|
|
153
|
+
} else {
|
|
154
|
+
routeToFiles.set(r, (routeToFiles.get(r) || []).concat(files));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check app-path-routes-manifest for routes not in main app-build-manifest
|
|
159
|
+
// These are nested routes like /[locale]/money2020
|
|
160
|
+
for (const [routePath, manifestKey] of Object.entries(routePathToManifestKey)) {
|
|
161
|
+
// Skip if already processed from main app-build-manifest
|
|
162
|
+
if (appPages[manifestKey] || routePath.startsWith('/api/')) continue;
|
|
163
|
+
let r = cleanRoute(routePath);
|
|
164
|
+
if (isInternal(r) || isAssetRoute(r)) continue;
|
|
165
|
+
|
|
166
|
+
// Try to load nested manifest
|
|
167
|
+
const nestedPages = loadNestedManifest(manifestKey);
|
|
168
|
+
let files = [];
|
|
169
|
+
if (nestedPages && nestedPages[manifestKey]) {
|
|
170
|
+
files = nestedPages[manifestKey] || [];
|
|
171
|
+
} else if (nestedPages) {
|
|
172
|
+
// If nested manifest exists but key doesn't match, try first page entry
|
|
173
|
+
files = Object.values(nestedPages)[0] || [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If nested route has no chunks, inherit from parent pattern
|
|
177
|
+
// e.g., /[locale]/money2020 inherits from /[locale]
|
|
178
|
+
if (files.length === 0 && isDynamicRoute(r)) {
|
|
179
|
+
const parts = r.split('/').filter(Boolean);
|
|
180
|
+
if (parts.length > 1) {
|
|
181
|
+
const parentPattern = '/' + parts[0]; // /[locale]
|
|
182
|
+
if (dynamicPatternToFiles.has(parentPattern)) {
|
|
183
|
+
files = dynamicPatternToFiles.get(parentPattern);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isDynamicRoute(r)) {
|
|
189
|
+
dynamicPatternToFiles.set(r, files);
|
|
190
|
+
} else {
|
|
191
|
+
routeToFiles.set(r, (routeToFiles.get(r) || []).concat(files));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Pages Router fallback
|
|
196
|
+
for (const key of Object.keys(pages)) {
|
|
197
|
+
if (isInternal(key)) continue;
|
|
198
|
+
let r = cleanRoute(key);
|
|
199
|
+
if (isAssetRoute(r)) continue;
|
|
200
|
+
const files = pages[key] || [];
|
|
201
|
+
const runtime = buildManifest.runtime || [];
|
|
202
|
+
if (isDynamicRoute(r)) {
|
|
203
|
+
dynamicPatternToFiles.set(r, files.concat(runtime));
|
|
204
|
+
} else {
|
|
205
|
+
routeToFiles.set(r, (routeToFiles.get(r) || []).concat(files, runtime));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Helper: find which dynamic pattern a concrete route matches
|
|
210
|
+
// Only matches routes that are actually from dynamic patterns (not just regex coincidence)
|
|
211
|
+
function findMatchingPattern(concreteRoute) {
|
|
212
|
+
// Check prerender manifest's dynamicRoutes to see if this route is from a dynamic pattern
|
|
213
|
+
const dynamicRoutes = prerenderManifest.dynamicRoutes || {};
|
|
214
|
+
for (const [patternPath] of Object.entries(dynamicRoutes)) {
|
|
215
|
+
// Convert pattern to regex: /[locale] -> /[^/]+
|
|
216
|
+
const regexStr = patternPath
|
|
217
|
+
.replace(/\[\.\.\.(\w+)\]/, '.*') // catch-all: /[...slug] -> .*
|
|
218
|
+
.replace(/\[(\w+)\]/g, '[^/]+'); // single: /[locale] -> [^/]+
|
|
219
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
220
|
+
if (regex.test(concreteRoute)) {
|
|
221
|
+
// Verify this pattern exists in our dynamic patterns
|
|
222
|
+
const cleanPattern = cleanRoute(patternPath);
|
|
223
|
+
if (dynamicPatternToFiles.has(cleanPattern)) {
|
|
224
|
+
return cleanPattern;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Prerender routes: map concrete routes to their dynamic pattern chunks
|
|
232
|
+
for (const key of Object.keys(prerenderManifest.routes || {})) {
|
|
233
|
+
if (isInternal(key)) continue;
|
|
234
|
+
let r = cleanRoute(key);
|
|
235
|
+
if (isAssetRoute(r)) continue;
|
|
236
|
+
if (isDynamicRoute(r)) continue; // Skip dynamic route patterns themselves
|
|
237
|
+
if (!routeToFiles.has(r)) {
|
|
238
|
+
// Try to find matching dynamic pattern
|
|
239
|
+
const pattern = findMatchingPattern(r);
|
|
240
|
+
if (pattern && dynamicPatternToFiles.has(pattern)) {
|
|
241
|
+
routeToFiles.set(r, dynamicPatternToFiles.get(pattern));
|
|
242
|
+
routesFromDynamicPatterns.add(r); // Mark as coming from dynamic pattern
|
|
243
|
+
} else {
|
|
244
|
+
routeToFiles.set(r, []); // No chunks found
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Note: We track route patterns (e.g., /[locale], /[locale]/money2020) in the output,
|
|
249
|
+
// not concrete instances (e.g., /en, /en/money2020). The patterns represent all instances.
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Add dynamic route patterns to output (e.g., /[locale], /[locale]/money2020)
|
|
253
|
+
// These represent the patterns themselves, not concrete instances
|
|
254
|
+
for (const [pattern, files] of dynamicPatternToFiles.entries()) {
|
|
255
|
+
// Only add if not already in routeToFiles (avoid duplicates)
|
|
256
|
+
if (!routeToFiles.has(pattern)) {
|
|
257
|
+
routeToFiles.set(pattern, files);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const routes = [];
|
|
262
|
+
for (const [route, files] of routeToFiles.entries()) {
|
|
263
|
+
const { total, thirdParty } = sumFiles(projectRoot, files);
|
|
264
|
+
routes.push({
|
|
265
|
+
path: route,
|
|
266
|
+
jsGzipBytes: total,
|
|
267
|
+
thirdPartyJsBytes: thirdParty,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return routes;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function main() {
|
|
274
|
+
const projectRoot = process.cwd();
|
|
275
|
+
const routes = collectRoutes(projectRoot);
|
|
276
|
+
const outPath = path.join(projectRoot, '.tracelet', 'stats.json');
|
|
277
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
278
|
+
fs.writeFileSync(outPath, JSON.stringify({ routes }, null, 2));
|
|
279
|
+
console.log(`[tracelet] wrote stats to ${path.relative(projectRoot, outPath)}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (require.main === module) {
|
|
283
|
+
main();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Export for programmatic use
|
|
287
|
+
module.exports = main;
|
package/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracelet Next.js Plugin
|
|
5
|
+
*
|
|
6
|
+
* This package provides the adapter script to collect Next.js route statistics.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* 1. Add to package.json scripts:
|
|
10
|
+
* "postbuild": "node node_modules/tracelet-next/collect.js"
|
|
11
|
+
*
|
|
12
|
+
* 2. Or call manually after build:
|
|
13
|
+
* node node_modules/tracelet-next/collect.js
|
|
14
|
+
*
|
|
15
|
+
* The script reads Next.js build manifests and generates .tracelet/stats.json
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Export the collect function for programmatic use
|
|
19
|
+
module.exports = function collect() {
|
|
20
|
+
require('./collect.js');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Main entry point (can be called directly)
|
|
24
|
+
if (require.main === module) {
|
|
25
|
+
module.exports();
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@traceletdev/next",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Tracelet plugin for Next.js - automatically collects route statistics after builds",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"postinstall": "node collect.js"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@traceletdev/cli": "^0.5.0",
|
|
15
|
+
"next": ">=13.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tracelet",
|
|
19
|
+
"nextjs",
|
|
20
|
+
"performance",
|
|
21
|
+
"plugin"
|
|
22
|
+
],
|
|
23
|
+
"author": "Moiz Imran <moizwasti@gmail.com>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/traceletdev/tracelet.git",
|
|
28
|
+
"directory": "packages/tracelet-next"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"collect.js",
|
|
33
|
+
"README.md"
|
|
34
|
+
]
|
|
35
|
+
}
|