From 99a3ffcf179317642a420f265d72a2b8224c4d03 Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 1 Nov 2025 16:04:47 +0100 Subject: [PATCH] optimizations, use md5 for hash --- interface/package.json | 1 + interface/pnpm-lock.yaml | 9 + interface/progmem-generator.js | 8 +- interface/vite.config.ts | 457 ++++++++++++++------------------- src/ESP32React/ESP32React.cpp | 56 ++-- 5 files changed, 236 insertions(+), 295 deletions(-) diff --git a/interface/package.json b/interface/package.json index bc004da04..e4893f594 100644 --- a/interface/package.json +++ b/interface/package.json @@ -31,6 +31,7 @@ "@table-library/react-table-library": "4.1.15", "alova": "3.3.4", "async-validator": "^4.2.5", + "etag": "^1.8.1", "formidable": "^3.5.4", "jwt-decode": "^4.0.0", "magic-string": "^0.30.21", diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 38d7a29ed..4a09812a0 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: async-validator: specifier: ^4.2.5 version: 4.2.5 + etag: + specifier: ^1.8.1 + version: 1.8.1 formidable: specifier: ^3.5.4 version: 3.5.4 @@ -1552,6 +1555,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + exec-buffer@3.2.0: resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==} engines: {node: '>=4'} @@ -4563,6 +4570,8 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + exec-buffer@3.2.0: dependencies: execa: 0.7.0 diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index 52c304b1d..30fc00094 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -1,10 +1,9 @@ -import crypto from 'crypto'; +import etag from 'etag'; import { createWriteStream, existsSync, readFileSync, readdirSync, - statSync, unlinkSync } from 'fs'; import mime from 'mime-types'; @@ -36,7 +35,7 @@ const generateWWWClass = class WWWData { ${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')} +${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')} ${INDENT.repeat(2)}} }; `; @@ -71,7 +70,8 @@ const writeFile = (relativeFilePath, buffer) => { writeStream.write(`const uint8_t ${variable}[] = {`); const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); - const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); + // const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); + const hash = etag(zipBuffer); // use smaller md5 instead of sha256 zipBuffer.forEach((b) => { if (!(size % bytesPerLine)) { diff --git a/interface/vite.config.ts b/interface/vite.config.ts index d92aec919..1dd1da6b6 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -2,8 +2,7 @@ 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 { Plugin } from 'vite'; +import { defineConfig, Plugin } from 'vite'; import viteImagemin from 'vite-plugin-imagemin'; import viteTsconfigPaths from 'vite-tsconfig-paths'; import zlib from 'zlib'; @@ -11,6 +10,29 @@ import zlib from 'zlib'; // @ts-expect-error - mock server doesn't have type declarations import mockServer from '../mock-api/mockServer.js'; +// Constants +const KB_DIVISOR = 1024; +const REPEAT_CHAR = '='; +const REPEAT_COUNT = 50; +const DEFAULT_OUT_DIR = 'dist'; +const ES_TARGET = 'es2020'; +const CHUNK_SIZE_WARNING_LIMIT = 512; +const ASSETS_INLINE_LIMIT = 4096; + +// Common resolve aliases +const RESOLVE_ALIASES = { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime' +}; + +// Bundle file interface +interface BundleFile { + name: string; + size: number; + gzipSize: number; +} + // Plugin to display bundle size information const bundleSizeReporter = (): Plugin => { return { @@ -18,99 +40,190 @@ const bundleSizeReporter = (): Plugin => { // eslint-disable-next-line @typescript-eslint/no-explicit-any writeBundle(options: any, bundle: any) { console.log('\nšŸ“¦ Bundle Size Report:'); - console.log('='.repeat(50)); + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); - let totalSize = 0; - const files: Array<{ name: string; size: number; gzipSize?: number }> = []; - - 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; + const files: BundleFile[] = []; + const outDir = options.dir || DEFAULT_OUT_DIR; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const bundleEntries: Array<[string, any]> = Object.entries(bundle); + for (const [fileName, chunk] of bundleEntries) { + if (chunk?.type === 'chunk' || chunk?.type === 'asset') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const filePath = path.join(outDir, fileName); try { const stats = fs.statSync(filePath); - size = stats.size; - totalSize += size; - - // Calculate gzip size + const size = stats.size; const fileContent = fs.readFileSync(filePath); - gzipSize = zlib.gzipSync(fileContent).length; + const gzipSize = zlib.gzipSync(fileContent).length; - files.push({ - name: fileName, - size, - gzipSize - }); + 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'; + const sizeKB = (file.size / KB_DIVISOR).toFixed(2); + const gzipKB = (file.gzipSize / KB_DIVISOR).toFixed(2); 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`); + const totalSize = files.reduce((sum, file) => sum + file.size, 0); + const totalGzipSize = files.reduce((sum, file) => sum + file.gzipSize, 0); + const compressionRatio = ((totalSize - totalGzipSize) / totalSize) * 100; - // 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)); + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); + console.log(`šŸ“Š Total Bundle Size: ${(totalSize / KB_DIVISOR).toFixed(2)} KB`); + console.log(`šŸ—œļø Total Gzipped Size: ${(totalGzipSize / KB_DIVISOR).toFixed(2)} KB`); + console.log(`šŸ“ˆ Compression Ratio: ${compressionRatio.toFixed(1)}%`); + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); } }; }; +// Common preact plugin config +const createPreactPlugin = (devToolsEnabled: boolean) => + preact({ + devToolsEnabled, + prefreshEnabled: false + }); + +// Common base plugins +const createBasePlugins = (devToolsEnabled: boolean, includeBundleReporter = true) => { + const plugins = [ + createPreactPlugin(devToolsEnabled), + viteTsconfigPaths() + ]; + if (includeBundleReporter) { + plugins.push(bundleSizeReporter()); + } + return plugins; +}; + +// Manual chunk splitting strategy +const createManualChunks = (detailed = false) => { + return (id: string): string | undefined => { + if (id.includes('node_modules')) { + if (id.includes('preact')) return '@preact'; + if (detailed) { + if (id.includes('react-router')) return '@react-router'; + if (id.includes('@mui/material')) return '@mui-material'; + if (id.includes('@mui/icons-material')) return '@mui-icons'; + if (id.includes('alova')) return '@alova'; + if (id.includes('typesafe-i18n')) return '@i18n'; + if (id.includes('react-toastify')) return '@toastify'; + if (id.includes('@table-library')) return '@table-library'; + 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'; + } + if (detailed) { + if (id.includes('components/')) return 'components'; + if (id.includes('app/')) return 'app'; + if (id.includes('utils/')) return 'utils'; + if (id.includes('api/')) return 'api'; + } + return undefined; + }; +}; + +// Common build base configuration +const createBaseBuildConfig = () => ({ + target: ES_TARGET, + chunkSizeWarningLimit: CHUNK_SIZE_WARNING_LIMIT, + cssMinify: true, + assetsInlineLimit: ASSETS_INLINE_LIMIT +}); + +// Terser options for hosted builds +const createHostedTerserOptions = () => ({ + compress: { + passes: 3, + drop_console: true, + drop_debugger: true, + dead_code: true, + unused: true + }, + mangle: { + toplevel: true + }, + ecma: 2020 as const +}); + +// Terser options for production builds +const createProductionTerserOptions = () => ({ + compress: { + passes: 6, + arrows: true, + drop_console: true, + drop_debugger: true, + sequences: true + }, + mangle: { + toplevel: true, + module: true + }, + ecma: 2020 as const, + enclose: false, + keep_classnames: false, + keep_fnames: false, + ie8: false, + module: false, + safari10: false, + toplevel: true +}); + +// Image optimization plugin +const imageOptimizationPlugin = { + ...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' as const +}; + export default defineConfig( ({ command, mode }: { command: string; mode: string }) => { if (command === 'serve') { - console.log('Preparing for standalone build with server, mode=' + mode); + console.log(`Preparing for standalone build with server, mode=${mode}`); return { plugins: [ - preact({ - // Keep dev tools enabled for development - devToolsEnabled: true, - prefreshEnabled: false - }), - viteTsconfigPaths(), - bundleSizeReporter(), // Add bundle size reporting + ...createBasePlugins(true, true), mockServer() ], resolve: { - alias: { - react: 'preact/compat', - 'react-dom': 'preact/compat', - 'react/jsx-runtime': 'preact/jsx-runtime' - } + alias: RESOLVE_ALIASES }, server: { open: true, - port: mode == 'production' ? 4173 : 3000, + port: mode === 'production' ? 4173 : 3000, proxy: { '/api': { target: 'http://localhost:3080', @@ -118,14 +231,13 @@ export default defineConfig( secure: false }, '/rest': 'http://localhost:3080', - '/gh': 'http://localhost:3080' // mock for GitHub API + '/gh': 'http://localhost:3080' } }, - // Optimize development builds build: { - target: 'es2020', - minify: false, // Disable minification for faster dev builds - sourcemap: true // Enable source maps for debugging + target: ES_TARGET, + minify: false, + sourcemap: true } }; } @@ -133,55 +245,20 @@ export default defineConfig( 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 - ], + plugins: createBasePlugins(false, true), resolve: { - alias: { - react: 'preact/compat', - 'react-dom': 'preact/compat', - 'react/jsx-runtime': 'preact/jsx-runtime' - } + alias: RESOLVE_ALIASES }, 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 - }, + ...createBaseBuildConfig(), + minify: 'terser' as const, + terserOptions: createHostedTerserOptions(), rollupOptions: { treeshake: { moduleSideEffects: false }, output: { - manualChunks(id: string) { - if (id.includes('node_modules')) { - if (id.includes('preact')) { - return '@preact'; - } - return 'vendor'; - } - return undefined; - } + manualChunks: createManualChunks(false) } } } @@ -192,118 +269,24 @@ export default defineConfig( 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' - }, + ...createBasePlugins(false, true), + imageOptimizationPlugin, visualizer({ - template: 'treemap', // or sunburst + template: 'treemap', open: false, gzipSize: true, brotliSize: true, - filename: '../analyse.html' // will be saved in project's root - }), - bundleSizeReporter() // Add bundle size reporting + filename: '../analyse.html' + }) ], - resolve: { - alias: { - react: 'preact/compat', - 'react-dom': 'preact/compat', - 'react/jsx-runtime': 'preact/jsx-runtime' - } + alias: RESOLVE_ALIASES }, - build: { - // Target modern browsers for smaller bundles - target: 'es2020', - chunkSizeWarningLimit: 512, - minify: 'terser', - // Enable CSS minification - cssMinify: true, - // Optimize asset handling - assetsInlineLimit: 4096, // Inline small assets - terserOptions: { - compress: { - passes: 6, - 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 - }, - + ...createBaseBuildConfig(), + minify: 'terser' as const, + terserOptions: createProductionTerserOptions(), rollupOptions: { - // Enable aggressive tree shaking treeshake: { moduleSideEffects: false, propertyReadSideEffects: false, @@ -311,65 +294,11 @@ export default defineConfig( unknownGlobalSideEffects: 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('@mui/material')) { - return '@mui-material'; - } - if (id.includes('@mui/icons-material')) { - return '@mui-icons'; - } - if (id.includes('alova')) { - return '@alova'; - } - if (id.includes('typesafe-i18n')) { - return '@i18n'; - } - if (id.includes('react-toastify')) { - return '@toastify'; - } - if (id.includes('@table-library')) { - return '@table-library'; - } - 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('app/')) { - return 'app'; - } - if (id.includes('utils/')) { - return 'utils'; - } - if (id.includes('api/')) { - return 'api'; - } - return undefined; - }, - // Enable source maps for debugging (optional) - sourcemap: false // Disable for production to save space + manualChunks: createManualChunks(true), + sourcemap: false } } } diff --git a/src/ESP32React/ESP32React.cpp b/src/ESP32React/ESP32React.cpp index 0047528c2..72bd28a2d 100644 --- a/src/ESP32React/ESP32React.cpp +++ b/src/ESP32React/ESP32React.cpp @@ -19,47 +19,49 @@ ESP32React::ESP32React(AsyncWebServer * server, FS * fs) // Serve static web resources // - // Populate the last modification date based on build datetime - static char last_modified[50]; - sprintf(last_modified, "%s %s CET", __DATE__, __TIME__); + ArRequestHandlerFunction indexHtmlHandler = nullptr; - WWWData::registerRoutes([server](const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash) { + WWWData::registerRoutes([server, &indexHtmlHandler](const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash) { ArRequestHandlerFunction requestHandler = [contentType, content, len, hash](AsyncWebServerRequest * request) { + AsyncWebServerResponse * response; + // Check if the client already has the same version and respond with a 304 (Not modified) - if (request->header("If-Modified-Since").indexOf(last_modified) > 0) { - return request->send(304); - } else if (request->header("If-None-Match").equals(hash)) { - return request->send(304); + if (request->header("If-None-Match").equals(hash)) { + response = request->beginResponse(304); + } else { + response = request->beginResponse(200, contentType, content, len); + response->addHeader("Content-Encoding", "gzip"); // not br for brotlin only works over HTTPS } - AsyncWebServerResponse * response = request->beginResponse(200, contentType, content, len); - - response->addHeader("Content-Encoding", "gzip"); - // response->addHeader("Content-Encoding", "br"); // only works over HTTPS - // response->addHeader("Cache-Control", "public, immutable, max-age=31536000"); - response->addHeader("Cache-Control", "must-revalidate"); // ensure that a client will check the server for a change - response->addHeader("Last-Modified", last_modified); + // always send these headers - see https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 response->addHeader("ETag", hash); + response->addHeader("Cache-Control", "no-cache"); // Requires revalidation before using cached content (ETags enable 304 responses) request->send(response); }; server->on(uri, HTTP_GET, requestHandler); - // Serving non matching get requests with "/index.html" - // OPTIONS get a straight up 200 response - if (strncmp(uri, "/index.html", 11) == 0) { - server->onNotFound([requestHandler](AsyncWebServerRequest * request) { - if (request->method() == HTTP_GET) { - requestHandler(request); - } else if (request->method() == HTTP_OPTIONS) { - request->send(200); - } else { - request->send(404); - } - }); + // Capture index.html handler to set onNotFound once after all routes are registered + if (strcmp(uri, "/index.html") == 0) { + indexHtmlHandler = requestHandler; } }); + + // Set onNotFound handler once after all routes are registered + // Serving non matching get requests with "/index.html" + // OPTIONS get a straight up 200 response + if (indexHtmlHandler != nullptr) { + server->onNotFound([indexHtmlHandler](AsyncWebServerRequest * request) { + if (request->method() == HTTP_GET) { + indexHtmlHandler(request); + } else if (request->method() == HTTP_OPTIONS) { + request->send(200); + } else { + request->send(404); // not found + } + }); + } } void ESP32React::begin() {