diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index 52ec26625..52c304b1d 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -4,6 +4,7 @@ import { existsSync, readFileSync, readdirSync, + statSync, unlinkSync } from 'fs'; import mime from 'mime-types'; @@ -15,67 +16,79 @@ const INDENT = ' '; const outputPath = '../src/ESP32React/WWWData.h'; const sourcePath = './dist'; const bytesPerLine = 20; -var totalSize = 0; +let totalSize = 0; +let bundleStats = { + js: { count: 0, uncompressed: 0, compressed: 0 }, + css: { count: 0, uncompressed: 0, compressed: 0 }, + html: { count: 0, uncompressed: 0, compressed: 0 }, + svg: { count: 0, uncompressed: 0, compressed: 0 }, + other: { count: 0, uncompressed: 0, compressed: 0 } +}; -const generateWWWClass = () => - `typedef std::function RouteRegistrationHandler; -// Total size is ${totalSize} bytes +const generateWWWClass = + () => `typedef std::function RouteRegistrationHandler; +// Bundle Statistics: +// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB +// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB +// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}% +// - Generated on: ${new Date().toISOString()} class WWWData { -${indent}public: -${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { -${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')} -${indent.repeat(2)}} +${INDENT}public: +${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { +${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "${f.hash}");`).join('\n')} +${INDENT.repeat(2)}} }; `; -function getFilesSync(dir, files = []) { +const getFilesSync = (dir, files = []) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => { const entryPath = resolve(dir, entry.name); - if (entry.isDirectory()) { - getFilesSync(entryPath, files); - } else { - files.push(entryPath); - } + entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath); }); return files; -} +}; -function cleanAndOpen(path) { - if (existsSync(path)) { - unlinkSync(path); - } +const cleanAndOpen = (path) => { + existsSync(path) && unlinkSync(path); return createWriteStream(path, { flags: 'w+' }); -} +}; + +const getFileType = (filePath) => { + const ext = filePath.split('.').pop().toLowerCase(); + if (ext === 'js') return 'js'; + if (ext === 'css') return 'css'; + if (ext === 'html') return 'html'; + if (ext === 'svg') return 'svg'; + return 'other'; +}; const writeFile = (relativeFilePath, buffer) => { - const variable = 'ESP_REACT_DATA_' + fileInfo.length; + const variable = `ESP_REACT_DATA_${fileInfo.length}`; const mimeType = mime.lookup(relativeFilePath); - var size = 0; - writeStream.write('const uint8_t ' + variable + '[] = {'); - // const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 }); - const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); + const fileType = getFileType(relativeFilePath); + let size = 0; + writeStream.write(`const uint8_t ${variable}[] = {`); - // create sha - const hashSum = crypto.createHash('sha256'); - hashSum.update(zipBuffer); - const hash = hashSum.digest('hex'); + const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); + const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); zipBuffer.forEach((b) => { if (!(size % bytesPerLine)) { - writeStream.write('\n'); - writeStream.write(indent); + writeStream.write('\n' + INDENT); } - writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ','); + writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ','); size++; }); - if (size % bytesPerLine) { - writeStream.write('\n'); - } - + size % bytesPerLine && writeStream.write('\n'); writeStream.write('};\n\n'); + // Update bundle statistics + bundleStats[fileType].count++; + bundleStats[fileType].uncompressed += buffer.length; + bundleStats[fileType].compressed += zipBuffer.length; + fileInfo.push({ uri: '/' + relativeFilePath.replace(sep, '/'), mimeType, @@ -84,32 +97,52 @@ const writeFile = (relativeFilePath, buffer) => { hash }); - // console.log(relativeFilePath + ' (size ' + size + ' bytes)'); totalSize += size; }; -// start -console.log('Generating ' + outputPath + ' from ' + sourcePath); -const includes = ARDUINO_INCLUDES; -const indent = INDENT; +console.log(`Generating ${outputPath} from ${sourcePath}`); const fileInfo = []; const writeStream = cleanAndOpen(resolve(outputPath)); -// includes -writeStream.write(includes); +writeStream.write(ARDUINO_INCLUDES); -// process static files const buildPath = resolve(sourcePath); for (const filePath of getFilesSync(buildPath)) { - const readStream = readFileSync(filePath); - const relativeFilePath = relative(buildPath, filePath); - writeFile(relativeFilePath, readStream); + writeFile(relative(buildPath, filePath), readFileSync(filePath)); } -// add class writeStream.write(generateWWWClass()); - -// end writeStream.end(); -console.log('Total size: ' + totalSize / 1000 + ' KB'); +// Calculate and display bundle statistics +const totalUncompressed = Object.values(bundleStats).reduce( + (sum, stat) => sum + stat.uncompressed, + 0 +); +const totalCompressed = Object.values(bundleStats).reduce( + (sum, stat) => sum + stat.compressed, + 0 +); +const compressionRatio = ( + ((totalUncompressed - totalCompressed) / totalUncompressed) * + 100 +).toFixed(1); + +console.log('\nšŸ“Š Bundle Size Analysis:'); +console.log('='.repeat(50)); +console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`); +console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`); +console.log(`Compression ratio: ${compressionRatio}%`); +console.log('\nšŸ“ File Type Breakdown:'); +Object.entries(bundleStats).forEach(([type, stats]) => { + if (stats.count > 0) { + const ratio = ( + ((stats.uncompressed - stats.compressed) / stats.uncompressed) * + 100 + ).toFixed(1); + console.log( + `${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)` + ); + } +}); +console.log('='.repeat(50)); diff --git a/interface/tsconfig.json b/interface/tsconfig.json index 3e8dfeb47..ec73ad764 100644 --- a/interface/tsconfig.json +++ b/interface/tsconfig.json @@ -1,31 +1,108 @@ { "compilerOptions": { - "target": "ESNext", + // Target modern browsers for better performance + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["node"], + + // Optimized library selection + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"], + + // JavaScript handling "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "composite": true, + "checkJs": false, + + // Module system optimized for Vite "module": "ESNext", "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, + + // Emit configuration "noEmit": true, - "useUnknownInCatchVariables": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + + // React/JSX configuration "jsx": "react-jsx", - "noImplicitAny": false, - "baseUrl": "src", + "jsxImportSource": "react", + + // Strict type checking for better code quality + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + + // Additional checks + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "useUnknownInCatchVariables": true, + + // Performance optimizations + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "incremental": true, + "tsBuildInfoFile": ".tsbuildinfo", + + // Path mapping for cleaner imports + "baseUrl": ".", "paths": { - "@": ["src"], - "@/*": ["src/*"] + "@/*": ["src/*"], + "@/components/*": ["src/components/*"], + "@/utils/*": ["src/utils/*"], + "@/types/*": ["src/types/*"], + "@/hooks/*": ["src/hooks/*"], + "@/services/*": ["src/services/*"], + "@/assets/*": ["src/assets/*"], + // Support for bare imports from src directory + "App": ["src/App"], + "AppRouting": ["src/AppRouting"], + "CustomTheme": ["src/CustomTheme"], + "SignIn": ["src/SignIn"], + "AuthenticatedRouting": ["src/AuthenticatedRouting"], + "env": ["src/env"], + "components": ["src/components"], + "contexts": ["src/contexts"], + "i18n": ["src/i18n"], + "utils": ["src/utils"], + "validators": ["src/validators"], + "types": ["src/types"], + "api": ["src/api"], + "app": ["src/app"], + // Wildcard patterns for subdirectories + "components/*": ["src/components/*"], + "contexts/*": ["src/contexts/*"], + "i18n/*": ["src/i18n/*"], + "utils/*": ["src/utils/*"], + "validators/*": ["src/validators/*"], + "types/*": ["src/types/*"], + "api/*": ["src/api/*"], + "app/*": ["src/app/*"] } }, - "include": ["src/**/*", "vite.config.ts"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*", "vite.config.ts", "progmem-generator.js"], + "exclude": [ + "node_modules", + "dist", + "build", + ".tsbuildinfo", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx" + ], + "ts-node": { + "esm": true + } } diff --git a/interface/vite.config.ts b/interface/vite.config.ts index 8c7b336a4..702efb08e 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -1,130 +1,330 @@ import preact from '@preact/preset-vite'; +import fs from 'fs'; +import path from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig } from 'vite'; -// import viteImagemin from 'vite-plugin-imagemin'; +import { Plugin } from 'vite'; +import viteImagemin from 'vite-plugin-imagemin'; import viteTsconfigPaths from 'vite-tsconfig-paths'; +import zlib from 'zlib'; +// @ts-expect-error - mock server doesn't have type declarations import mockServer from '../mock-api/mockServer.js'; -export default defineConfig(({ command, mode }) => { - if (command === 'serve') { - console.log('Preparing for standalone build with server, mode=' + mode); - return { - plugins: [preact(), viteTsconfigPaths(), mockServer()], - server: { - open: true, - port: mode == 'production' ? 4173 : 3000, - proxy: { - '/api': { - target: 'http://localhost:3080', - changeOrigin: true, - secure: false - }, - '/rest': 'http://localhost:3080', - '/gh': 'http://localhost:3080' // mock for GitHub API - } - } - }; - } - - if (mode === 'hosted') { - console.log('Preparing for hosted build'); - return { - plugins: [preact(), viteTsconfigPaths()], - build: { - chunkSizeWarningLimit: 1024 - } - }; - } - - console.log('Preparing for production, optimized build'); - +// Plugin to display bundle size information +const bundleSizeReporter = (): Plugin => { return { - plugins: [ - preact(), - viteTsconfigPaths(), - // { - // ...viteImagemin({ - // verbose: false, - // gifsicle: { - // optimizationLevel: 7, - // interlaced: false - // }, - // optipng: { - // optimizationLevel: 7 - // }, - // mozjpeg: { - // quality: 20 - // }, - // pngquant: { - // quality: [0.8, 0.9], - // speed: 4 - // }, - // svgo: { - // plugins: [ - // { - // name: 'removeViewBox' - // }, - // { - // name: 'removeEmptyAttrs', - // active: false - // } - // ] - // } - // }), - // enforce: 'pre' - // }, - visualizer({ - template: 'treemap', // or sunburst - open: false, - gzipSize: true, - brotliSize: true, - filename: '../analyse.html' // will be saved in project's root - }) - ], + name: 'bundle-size-reporter', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + writeBundle(options: any, bundle: any) { + console.log('\nšŸ“¦ Bundle Size Report:'); + console.log('='.repeat(50)); - build: { - // target: 'es2022', - chunkSizeWarningLimit: 1024, - minify: 'terser', - terserOptions: { - compress: { - passes: 4, - arrows: true, - drop_console: true, - drop_debugger: true, - sequences: true - }, - mangle: { - // toplevel: true - // module: true - // properties: { - // regex: /^_/ - // } - }, - ecma: 5, - enclose: false, - keep_classnames: false, - keep_fnames: false, - ie8: false, - module: false, - safari10: false, - toplevel: false - }, + let totalSize = 0; + const files: Array<{ name: string; size: number; gzipSize?: number }> = []; - rollupOptions: { - output: { - manualChunks(id: string) { - if (id.includes('node_modules')) { - // creating a chunk to react routes deps. Reducing the vendor chunk size - if (id.includes('react-router')) { - return '@react-router'; - } - return 'vendor'; - } + for (const [fileName, chunk] of Object.entries( + bundle as Record + )) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((chunk as any).type === 'chunk' || (chunk as any).type === 'asset') { + const filePath = path.join((options.dir as string) || 'dist', fileName); + let size = 0; + let gzipSize = 0; + + try { + const stats = fs.statSync(filePath); + size = stats.size; + totalSize += size; + + // Calculate gzip size + const fileContent = fs.readFileSync(filePath); + gzipSize = zlib.gzipSync(fileContent).length; + + files.push({ + name: fileName, + size, + gzipSize + }); + } catch (error) { + console.warn(`Could not read file ${fileName}:`, error); } } } + + // Sort files by size (largest first) + files.sort((a, b) => b.size - a.size); + + // Display individual file sizes + files.forEach((file) => { + const sizeKB = (file.size / 1024).toFixed(2); + const gzipKB = file.gzipSize ? (file.gzipSize / 1024).toFixed(2) : 'N/A'; + console.log( + `šŸ“„ ${file.name.padEnd(30)} ${sizeKB.padStart(8)} KB (${gzipKB} KB gzipped)` + ); + }); + + console.log('='.repeat(50)); + console.log(`šŸ“Š Total Bundle Size: ${(totalSize / 1024).toFixed(2)} KB`); + + // Calculate and display gzip total + const totalGzipSize = files.reduce( + (sum, file) => sum + (file.gzipSize || 0), + 0 + ); + console.log(`šŸ—œļø Total Gzipped Size: ${(totalGzipSize / 1024).toFixed(2)} KB`); + + // Show compression ratio + const compressionRatio = ( + ((totalSize - totalGzipSize) / totalSize) * + 100 + ).toFixed(1); + console.log(`šŸ“ˆ Compression Ratio: ${compressionRatio}%`); + + console.log('='.repeat(50)); } }; -}); +}; + +export default defineConfig( + ({ command, mode }: { command: string; mode: string }) => { + if (command === 'serve') { + console.log('Preparing for standalone build with server, mode=' + mode); + return { + plugins: [ + preact({ + // Keep dev tools enabled for development + devToolsEnabled: true, + prefreshEnabled: true + }), + viteTsconfigPaths(), + bundleSizeReporter(), // Add bundle size reporting + mockServer() + ], + server: { + open: true, + port: mode == 'production' ? 4173 : 3000, + proxy: { + '/api': { + target: 'http://localhost:3080', + changeOrigin: true, + secure: false + }, + '/rest': 'http://localhost:3080', + '/gh': 'http://localhost:3080' // mock for GitHub API + } + }, + // Optimize development builds + build: { + target: 'es2020', + minify: false, // Disable minification for faster dev builds + sourcemap: true // Enable source maps for debugging + } + }; + } + + if (mode === 'hosted') { + console.log('Preparing for hosted build'); + return { + plugins: [ + preact({ + // Enable Preact optimizations for hosted build + devToolsEnabled: false, + prefreshEnabled: false + }), + viteTsconfigPaths(), + bundleSizeReporter() // Add bundle size reporting + ], + build: { + target: 'es2020', + chunkSizeWarningLimit: 512, + minify: 'terser', + cssMinify: true, + assetsInlineLimit: 4096, + terserOptions: { + compress: { + passes: 3, + drop_console: true, + drop_debugger: true, + dead_code: true, + unused: true + }, + mangle: { + toplevel: true + }, + ecma: 2020 + }, + rollupOptions: { + treeshake: { + moduleSideEffects: false + }, + output: { + manualChunks(id: string) { + if (id.includes('node_modules')) { + if (id.includes('preact')) { + return '@preact'; + } + return 'vendor'; + } + } + } + } + } + }; + } + + console.log('Preparing for production, optimized build'); + + return { + plugins: [ + preact({ + // Enable Preact optimizations + devToolsEnabled: false, + prefreshEnabled: false + }), + viteTsconfigPaths(), + // Enable image optimization for size reduction + { + ...viteImagemin({ + verbose: false, + gifsicle: { + optimizationLevel: 7, + interlaced: false + }, + optipng: { + optimizationLevel: 7 + }, + mozjpeg: { + quality: 20 + }, + pngquant: { + quality: [0.8, 0.9], + speed: 4 + }, + svgo: { + plugins: [ + { + name: 'removeViewBox' + }, + { + name: 'removeEmptyAttrs', + active: false + } + ] + } + }), + enforce: 'pre' + }, + visualizer({ + template: 'treemap', // or sunburst + open: false, + gzipSize: true, + brotliSize: true, + filename: '../analyse.html' // will be saved in project's root + }), + bundleSizeReporter() // Add bundle size reporting + ], + + build: { + // Target modern browsers for smaller bundles + target: 'es2020', + chunkSizeWarningLimit: 512, // Reduced warning threshold + minify: 'terser', + // Enable CSS minification + cssMinify: true, + // Optimize asset handling + assetsInlineLimit: 4096, // Inline small assets + terserOptions: { + compress: { + passes: 6, // Increased passes for better compression + arrows: true, + drop_console: true, + drop_debugger: true, + sequences: true, + // Additional aggressive compression options + dead_code: true, + hoist_funs: true, + hoist_vars: true, + if_return: true, + join_vars: true, + loops: true, + pure_getters: true, + reduce_vars: true, + side_effects: false, + switches: true, + unsafe: true, + unsafe_arrows: true, + unsafe_comps: true, + unsafe_Function: true, + unsafe_math: true, + unsafe_proto: true, + unsafe_regexp: true, + unsafe_undefined: true, + unused: true + }, + mangle: { + toplevel: true, // Enable top-level mangling + module: true // Enable module mangling + // properties: { + // regex: /^_/ // Mangle properties starting with _ + // } + }, + ecma: 2020, // Updated to modern ECMAScript + enclose: false, + keep_classnames: false, + keep_fnames: false, + ie8: false, + module: false, + safari10: false, + toplevel: true // Enable top-level optimization + }, + + rollupOptions: { + // Enable tree shaking + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, + tryCatchDeoptimization: false + }, + output: { + // Optimize chunk naming for better caching + chunkFileNames: 'assets/[name]-[hash].js', + entryFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + manualChunks(id: string) { + if (id.includes('node_modules')) { + // More granular chunk splitting for better caching + if (id.includes('react-router')) { + return '@react-router'; + } + if (id.includes('preact')) { + return '@preact'; + } + if (id.includes('uuid')) { + return '@uuid'; + } + if (id.includes('axios') || id.includes('fetch')) { + return '@http'; + } + if (id.includes('lodash') || id.includes('ramda')) { + return '@utils'; + } + return 'vendor'; + } + // Split large application modules + if (id.includes('components/')) { + return 'components'; + } + if (id.includes('pages/') || id.includes('routes/')) { + return 'pages'; + } + return undefined; + }, + // Enable source maps for debugging (optional) + sourcemap: false // Disable for production to save space + } + } + } + }; + } +);