@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.
Files changed (4) hide show
  1. package/README.md +44 -0
  2. package/collect.js +287 -0
  3. package/index.js +26 -0
  4. 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
+ }