Files
self/app/scripts/analyze-tree-shaking.cjs
Justin Hernandez 82d26669bc Enable tree-shakeable exports (#823)
* Add tree-shakeable exports

* Migrate imports for tree-shakeable paths

* Document ESM extension requirement

* udpates

* install new lock

* yarn nice

* build deps

* save working index export no wildcard approach

* save wip

* fix building

* add tree shaking doc and examples

* sort package json files

* update package.json

* fix analyzing web

* make sure that web is built

* wip tree shaking

* building works again. save wip logic

* use granular imports

* wip test

* save wip

* Remove hardcoded .d.ts files and setup automatic TypeScript declaration generation

- Remove redundant constants.d.ts, types.d.ts, utils.d.ts files
- Add build:types script to automatically generate TypeScript declarations
- Update tsup config to disable DTS generation (handled separately)
- Update .gitignore to prevent future commits of generated .d.ts files
- Fixes import resolution errors in app by ensuring declarations are always generated

* Add .gitignore rules for generated TypeScript declarations

* ignore dts files

* Remove redundant index.js re-export files

- Remove constants.js, types.js, utils.js as they're redundant with tsup build
- These were just re-exports pointing to dist files that tsup generates
- package.json exports already point directly to built files
- Update .gitignore to prevent future commits of these generated files
- tsup handles all the building, no manual re-export files needed

* save current wip fixes

* add tsup config for web building

* common prettier and fix imports

* prettier

* fix tests

* implement level 3 tree shaking

* improve splitting

* optimize vite web building and prettier

* remove comments

* sort export params

* feedback and fix pipelines

* fix circuit-names path

* fix test

* fix building

* sort

* fix building

* allow cursor to edit scripts

* fix loadDocumentCatalog undefined

* fix build settings

* fix build settings

* additional metro tree shaking

* improved discovery script for xcode building

* pr feedback and fix camelCasing

* simplify shim setup

* fix xcode building and add command to test building

* remove comment

* simplify
2025-08-02 16:55:05 -07:00

642 lines
20 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function formatBytes(bytes) {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i];
}
function analyzeWebBundle() {
console.log('🕸️ Analyzing Web Bundle for Tree Shaking');
console.log('=========================================');
const distDir = path.join(__dirname, '..', 'web', 'dist');
const assetsDir = path.join(distDir, 'assets');
if (!fs.existsSync(distDir)) {
console.log('❌ Web build not found. Run "yarn web:build" first.');
return;
}
// Analyze chunk sizes - check both dist/ and dist/assets/
let files = [];
if (fs.existsSync(assetsDir)) {
files = fs
.readdirSync(assetsDir)
.filter(f => f.endsWith('.js'))
.map(f => path.join('assets', f));
}
if (files.length === 0) {
files = fs.readdirSync(distDir).filter(f => f.endsWith('.js'));
}
console.log('\n📦 JavaScript Chunks:');
let totalSize = 0;
files.forEach(file => {
const filePath = path.join(distDir, file);
const size = fs.statSync(filePath).size;
totalSize += size;
// Categorize chunks - use just the filename for categorization
const fileName = path.basename(file);
let category = '📄';
if (fileName.includes('vendor-')) category = '📚';
if (fileName.includes('screens-')) category = '🖥️ ';
if (fileName.includes('index')) category = '🏠';
// Show filename with size, highlighting large chunks
const sizeInfo = formatBytes(size);
const isLarge = size > 500 * 1024; // > 500KB
const displayName = fileName.padEnd(40);
const sizeDisplay = isLarge ? `⚠️ ${sizeInfo}` : sizeInfo;
console.log(`${category} ${displayName} ${sizeDisplay}`);
});
console.log(`\n📊 Total JavaScript: ${formatBytes(totalSize)}`);
// Check for source maps (indicates tree shaking info)
const sourceMaps = files.filter(f => path.basename(f).endsWith('.map'));
if (sourceMaps.length > 0) {
console.log(`📍 Source maps available: ${sourceMaps.length} files`);
}
// Analyze vendor chunks for common imports
const vendorChunks = files.filter(f => path.basename(f).includes('vendor-'));
if (vendorChunks.length > 0) {
console.log('\n🔍 Vendor Chunk Analysis:');
vendorChunks.forEach(chunk => {
const size = fs.statSync(path.join(distDir, chunk)).size;
const chunkName = path.basename(chunk);
console.log(` ${chunkName}: ${formatBytes(size)}`);
});
}
// Look for @selfxyz/common usage patterns
console.log('\n🌳 Tree Shaking Indicators:');
try {
// Check if chunks are split (good for tree shaking)
const nonVendorChunks = files.filter(
f => !path.basename(f).includes('vendor-'),
);
if (nonVendorChunks.length > 1) {
console.log('✅ Code splitting enabled - helps with tree shaking');
}
// Check for multiple vendor chunks (indicates good chunking strategy)
if (vendorChunks.length > 1) {
console.log('✅ Multiple vendor chunks - good separation of concerns');
}
// Identify large chunks that could benefit from tree shaking
const largeChunks = files.filter(f => {
const size = fs.statSync(path.join(distDir, f)).size;
return size > 1024 * 1024; // > 1MB
});
if (largeChunks.length > 0) {
console.log('\n⚠ LARGE CHUNKS DETECTED:');
largeChunks.forEach(chunk => {
const size = fs.statSync(path.join(distDir, chunk)).size;
const chunkName = path.basename(chunk);
console.log(
` ${chunkName}: ${formatBytes(size)} - Consider tree shaking optimization`,
);
});
}
// Size-based heuristics
if (totalSize < 2 * 1024 * 1024) {
// Less than 2MB
console.log(
'✅ Reasonable total bundle size - tree shaking likely working',
);
} else {
console.log(
`⚠️ Large total bundle size (${formatBytes(totalSize)}) - significant tree shaking potential`,
);
}
} catch (error) {
console.log('❌ Could not analyze bundle details:', error.message);
}
}
function analyzeReactNativeBundle(platform) {
console.log(`📱 Analyzing React Native Bundle (${platform})`);
console.log('============================================');
// Use existing bundle analysis but with tree shaking focus
const bundleAnalyzeScript = path.join(__dirname, 'bundle-analyze-ci.cjs');
try {
console.log('🔨 Running bundle analysis...');
execSync(`node ${bundleAnalyzeScript} ${platform}`, {
stdio: 'inherit',
});
// Additional tree shaking specific analysis
const tmpDir = path.join(
require('os').tmpdir(),
'react-native-bundle-visualizer',
);
const reportPath = path.join(
tmpDir,
'OpenPassport',
'output',
'explorer.html',
);
if (fs.existsSync(reportPath)) {
console.log(`\n📊 Detailed bundle report: ${reportPath}`);
console.log('💡 Look for:');
console.log(' - Unused modules from @selfxyz/common');
console.log(' - Large vendor chunks that could be optimized');
console.log(' - Multiple copies of the same module');
}
} catch (error) {
console.log('❌ Bundle analysis failed:', error.message);
}
}
function categorizeImports(imports) {
const constants = [
'API_URL',
'API_URL_STAGING',
'countryCodes',
'commonNames',
'countries',
'PASSPORT_ATTESTATION_ID',
'ID_CARD_ATTESTATION_ID',
'DEFAULT_MAJORITY',
'CSCA_TREE_URL',
'DSC_TREE_URL',
'TREE_URL',
'TREE_URL_STAGING',
'PCR0_MANAGER_ADDRESS',
'RPC_URL',
'WS_DB_RELAYER',
];
const utils = [
'hash',
'flexiblePoseidon',
'customHasher',
'generateCommitment',
'generateNullifier',
'formatMrz',
'initPassportDataParsing',
'buildSMT',
'getLeafCscaTree',
'getLeafDscTree',
'generateCircuitInputsDSC',
'generateCircuitInputsRegister',
'generateCircuitInputsVCandDisclose',
'formatEndpoint',
'hashEndpointWithScope',
'stringToBigInt',
'bigIntToString',
'genMockIdDoc',
'generateMockDSC',
'genAndInitMockPassportData',
];
const types = [
'PassportData',
'DocumentCategory',
'CertificateData',
'PublicKeyDetailsECDSA',
'PublicKeyDetailsRSA',
'PassportMetadata',
'UserIdType',
'EndpointType',
'SelfApp',
'SelfAppDisclosureConfig',
'IdDocInput',
'Country3LetterCode',
];
const suggestions = [];
const constantImports = imports.filter(imp =>
constants.includes(imp.replace(/^type\s+/, '')),
);
const utilImports = imports.filter(imp =>
utils.includes(imp.replace(/^type\s+/, '')),
);
const typeImports = imports.filter(
imp =>
types.includes(imp.replace(/^type\s+/, '')) || imp.startsWith('type '),
);
if (constantImports.length > 0) {
suggestions.push({
category: 'constants',
imports: constantImports,
suggestion: `import { ${constantImports.join(', ')} } from '@selfxyz/common/constants';`,
});
}
if (utilImports.length > 0) {
suggestions.push({
category: 'utils',
imports: utilImports,
suggestion: `import { ${utilImports.join(', ')} } from '@selfxyz/common/utils';`,
});
}
if (typeImports.length > 0) {
suggestions.push({
category: 'types',
imports: typeImports,
suggestion: `import type { ${typeImports.map(t => t.replace(/^type\s+/, '')).join(', ')} } from '@selfxyz/common/types';`,
});
}
return suggestions;
}
function compareImportPatterns() {
console.log('\n🔬 Import Pattern Analysis');
console.log('==========================');
const srcDir = path.join(__dirname, '..', 'src');
if (!fs.existsSync(srcDir)) {
console.log('❌ Source directory not found');
return;
}
// Find TypeScript/JavaScript files
const findFiles = (dir, extensions = ['.ts', '.tsx', '.js', '.jsx']) => {
const files = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
if (fs.statSync(fullPath).isDirectory()) {
files.push(...findFiles(fullPath, extensions));
} else if (extensions.some(ext => item.endsWith(ext))) {
files.push(fullPath);
}
}
return files;
};
const files = findFiles(srcDir);
// Analyze import patterns
let totalFiles = 0;
let filesWithCommonImports = 0;
let starImports = 0;
let namedImports = 0;
let granularImports = 0;
const importPatterns = {
star: [],
mixed: [],
granular: [],
};
const fileConversionOpportunities = [];
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
totalFiles++;
// Check for @selfxyz/common imports
const commonImportRegex = /import.*from\s+['"]@selfxyz\/common[^'"]*['"]/g;
const matches = content.match(commonImportRegex) || [];
if (matches.length > 0) {
filesWithCommonImports++;
const fileInfo = {
file: path.relative(srcDir, file),
imports: [],
conversionOpportunities: [],
priority: 0,
};
matches.forEach(match => {
if (match.includes('* as')) {
starImports++;
importPatterns.star.push({
file: path.relative(srcDir, file),
import: match.trim(),
});
fileInfo.imports.push({ type: 'star', import: match.trim() });
fileInfo.priority += 3; // High priority for star imports
} else if (
match.includes('/constants') ||
match.includes('/utils') ||
match.includes('/types')
) {
granularImports++;
importPatterns.granular.push({
file: path.relative(srcDir, file),
import: match.trim(),
});
fileInfo.imports.push({ type: 'granular', import: match.trim() });
} else {
namedImports++;
importPatterns.mixed.push({
file: path.relative(srcDir, file),
import: match.trim(),
});
fileInfo.imports.push({ type: 'mixed', import: match.trim() });
fileInfo.priority += 1; // Medium priority for mixed imports
// Analyze what specific imports this file has and suggest granular equivalents
const namedImportMatches = match.match(/import\s+\{([^}]+)\}/);
if (namedImportMatches) {
const imports = namedImportMatches[1]
.split(',')
.map(i => i.trim())
.filter(i => i && !i.includes('type'));
const suggestions = categorizeImports(imports);
if (suggestions.length > 0) {
fileInfo.conversionOpportunities = suggestions;
}
}
}
});
if (fileInfo.priority > 0) {
fileConversionOpportunities.push(fileInfo);
}
}
});
console.log(`📁 Analyzed ${totalFiles} files`);
console.log(`📦 Files importing @selfxyz/common: ${filesWithCommonImports}`);
console.log(`⭐ Star imports (import *): ${starImports}`);
console.log(`📝 Named imports: ${namedImports}`);
console.log(`🎯 Granular imports: ${granularImports}`);
// Show recommendations
console.log('\n💡 OPTIMIZATION OPPORTUNITIES:');
if (starImports > 0) {
console.log(
`❌ Found ${starImports} star imports - these prevent tree shaking`,
);
if (importPatterns.star.length <= 5) {
console.log(' Examples:');
importPatterns.star.slice(0, 5).forEach(item => {
console.log(` 📄 ${item.file}: ${item.import}`);
});
}
}
if (namedImports > granularImports) {
console.log(
`⚠️ More mixed imports (${namedImports}) than granular (${granularImports})`,
);
console.log(
' Consider using granular imports like "@selfxyz/common/constants"',
);
}
if (granularImports > 0) {
console.log(`✅ Good: ${granularImports} granular imports found`);
}
// Calculate tree shaking score
const totalImports = starImports + namedImports + granularImports;
let score = 0;
if (totalImports > 0) {
score =
((granularImports * 100 + namedImports * 50) / (totalImports * 100)) *
100;
console.log(`\n📊 Tree Shaking Score: ${score.toFixed(1)}%`);
if (score < 50) {
console.log('🔴 Poor - Many star imports detected');
} else if (score < 80) {
console.log('🟡 Good - Mix of import patterns');
} else {
console.log('🟢 Excellent - Mostly granular imports');
}
}
// Show detailed conversion opportunities
if (fileConversionOpportunities.length > 0) {
console.log('\n🎯 CONVERSION OPPORTUNITIES BY IMPACT:');
console.log('=====================================');
// Group files by opportunity type
const opportunityGroups = {
highImpact: fileConversionOpportunities.filter(
f => f.imports.length >= 2,
),
constantsOnly: fileConversionOpportunities.filter(
f =>
f.conversionOpportunities.some(opp => opp.category === 'constants') &&
f.conversionOpportunities.length === 1,
),
utilsOnly: fileConversionOpportunities.filter(
f =>
f.conversionOpportunities.some(opp => opp.category === 'utils') &&
f.conversionOpportunities.length === 1,
),
typesOnly: fileConversionOpportunities.filter(
f =>
f.conversionOpportunities.some(opp => opp.category === 'types') &&
f.conversionOpportunities.length === 1,
),
mixedCategories: fileConversionOpportunities.filter(
f => f.conversionOpportunities.length > 1,
),
needsAnalysis: fileConversionOpportunities.filter(
f => f.conversionOpportunities.length === 0,
),
};
// Show High Impact Opportunities (multiple imports)
if (opportunityGroups.highImpact.length > 0) {
console.log(
'\n🚀 HIGH IMPACT OPPORTUNITIES (Multiple imports per file):',
);
opportunityGroups.highImpact
.sort((a, b) => b.imports.length - a.imports.length)
.forEach((fileInfo, index) => {
console.log(
`\n${index + 1}. 📄 ${fileInfo.file} (${fileInfo.imports.length} imports)`,
);
fileInfo.imports
.filter(imp => imp.type === 'mixed')
.forEach(imp => {
console.log(` ⚠️ ${imp.import}`);
});
if (fileInfo.conversionOpportunities.length > 0) {
console.log(' ✅ Convert to:');
fileInfo.conversionOpportunities.forEach(suggestion => {
console.log(` ${suggestion.suggestion}`);
});
}
const estimatedImprovement = fileInfo.imports.length * 2.5;
console.log(
` 📈 Estimated score improvement: +${estimatedImprovement.toFixed(1)}%`,
);
});
}
// Show by Category for easier batch conversion
if (opportunityGroups.constantsOnly.length > 0) {
console.log('\n🔧 CONSTANTS CONVERSION OPPORTUNITIES:');
console.log(' (Convert these together for consistency)');
opportunityGroups.constantsOnly.forEach(fileInfo => {
const suggestion = fileInfo.conversionOpportunities.find(
opp => opp.category === 'constants',
);
console.log(` 📄 ${fileInfo.file}`);
console.log(` ${suggestion.suggestion}`);
});
}
if (opportunityGroups.utilsOnly.length > 0) {
console.log('\n⚙ UTILS CONVERSION OPPORTUNITIES:');
console.log(' (Convert these together for consistency)');
opportunityGroups.utilsOnly.forEach(fileInfo => {
const suggestion = fileInfo.conversionOpportunities.find(
opp => opp.category === 'utils',
);
console.log(` 📄 ${fileInfo.file}`);
console.log(` ${suggestion.suggestion}`);
});
}
if (opportunityGroups.typesOnly.length > 0) {
console.log('\n🏷 TYPES CONVERSION OPPORTUNITIES:');
console.log(' (Convert these together for consistency)');
opportunityGroups.typesOnly.forEach(fileInfo => {
const suggestion = fileInfo.conversionOpportunities.find(
opp => opp.category === 'types',
);
console.log(` 📄 ${fileInfo.file}`);
console.log(` ${suggestion.suggestion}`);
});
}
if (opportunityGroups.mixedCategories.length > 0) {
console.log('\n🔀 MIXED CATEGORY OPPORTUNITIES:');
console.log(' (Files importing from multiple categories)');
opportunityGroups.mixedCategories.forEach(fileInfo => {
console.log(` 📄 ${fileInfo.file}`);
fileInfo.conversionOpportunities.forEach(suggestion => {
console.log(` ${suggestion.suggestion}`);
});
});
}
if (opportunityGroups.needsAnalysis.length > 0) {
console.log('\n❓ NEEDS MANUAL ANALYSIS:');
console.log(' (Imports not automatically categorized)');
opportunityGroups.needsAnalysis.forEach(fileInfo => {
console.log(` 📄 ${fileInfo.file}`);
fileInfo.imports
.filter(imp => imp.type === 'mixed')
.forEach(imp => {
console.log(` ${imp.import}`);
});
});
}
// Summary stats
console.log('\n📈 CONVERSION SUMMARY:');
console.log(
`🚀 High Impact: ${opportunityGroups.highImpact.length} files (multiple imports each)`,
);
console.log(
`🔧 Constants Only: ${opportunityGroups.constantsOnly.length} files`,
);
console.log(`⚙️ Utils Only: ${opportunityGroups.utilsOnly.length} files`);
console.log(`🏷️ Types Only: ${opportunityGroups.typesOnly.length} files`);
console.log(
`🔀 Mixed Categories: ${opportunityGroups.mixedCategories.length} files`,
);
console.log(
`❓ Needs Analysis: ${opportunityGroups.needsAnalysis.length} files`,
);
const potentialScoreImprovement = Math.min(
95,
score +
opportunityGroups.highImpact.length * 5 +
fileConversionOpportunities.length * 2,
);
console.log(
`🎯 Potential score after conversion: ~${potentialScoreImprovement.toFixed(1)}%`,
);
console.log('\n💡 RECOMMENDED CONVERSION ORDER:');
console.log('1. Start with HIGH IMPACT files (biggest score improvement)');
console.log('2. Batch convert by category (constants → utils → types)');
console.log('3. Handle mixed categories individually');
console.log('4. Manually analyze remaining files');
}
}
function main() {
const args = process.argv.slice(2);
const command = args[0];
console.log('🌳 Tree Shaking Bundle Analysis');
console.log('==============================');
switch (command) {
case 'web':
analyzeWebBundle();
break;
case 'android':
case 'ios':
analyzeReactNativeBundle(command);
break;
case 'imports':
compareImportPatterns();
break;
case 'all':
default:
compareImportPatterns();
console.log('\n');
analyzeWebBundle();
break;
}
if (!command || command === 'all') {
console.log('\n🚀 NEXT STEPS:');
console.log(
'1. Run "yarn test:tree-shaking" to test different import patterns',
);
console.log(
'2. Run "yarn analyze:tree-shaking android" for mobile bundle analysis',
);
console.log(
'3. Run "yarn analyze:tree-shaking web" after "yarn web:build"',
);
console.log(
'4. Check the generated reports for optimization opportunities',
);
}
}
if (require.main === module) {
main();
}
module.exports = {
analyzeWebBundle,
analyzeReactNativeBundle,
compareImportPatterns,
};