optimizations, use md5 for hash

This commit is contained in:
proddy
2025-11-01 16:04:47 +01:00
parent 0edb844225
commit 99a3ffcf17
5 changed files with 236 additions and 295 deletions

View File

@@ -31,6 +31,7 @@
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.15",
"alova": "3.3.4", "alova": "3.3.4",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4", "formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",

View File

@@ -35,6 +35,9 @@ importers:
async-validator: async-validator:
specifier: ^4.2.5 specifier: ^4.2.5
version: 4.2.5 version: 4.2.5
etag:
specifier: ^1.8.1
version: 1.8.1
formidable: formidable:
specifier: ^3.5.4 specifier: ^3.5.4
version: 3.5.4 version: 3.5.4
@@ -1552,6 +1555,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
exec-buffer@3.2.0: exec-buffer@3.2.0:
resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==} resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -4563,6 +4570,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
etag@1.8.1: {}
exec-buffer@3.2.0: exec-buffer@3.2.0:
dependencies: dependencies:
execa: 0.7.0 execa: 0.7.0

View File

@@ -1,10 +1,9 @@
import crypto from 'crypto'; import etag from 'etag';
import { import {
createWriteStream, createWriteStream,
existsSync, existsSync,
readFileSync, readFileSync,
readdirSync, readdirSync,
statSync,
unlinkSync unlinkSync
} from 'fs'; } from 'fs';
import mime from 'mime-types'; import mime from 'mime-types';
@@ -36,7 +35,7 @@ const generateWWWClass =
class WWWData { class WWWData {
${INDENT}public: ${INDENT}public:
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { ${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)}} ${INDENT.repeat(2)}}
}; };
`; `;
@@ -71,7 +70,8 @@ const writeFile = (relativeFilePath, buffer) => {
writeStream.write(`const uint8_t ${variable}[] = {`); writeStream.write(`const uint8_t ${variable}[] = {`);
const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); 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) => { zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) { if (!(size % bytesPerLine)) {

View File

@@ -2,8 +2,7 @@ import preact from '@preact/preset-vite';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite'; import { defineConfig, Plugin } from 'vite';
import { Plugin } from 'vite';
import viteImagemin from 'vite-plugin-imagemin'; import viteImagemin from 'vite-plugin-imagemin';
import viteTsconfigPaths from 'vite-tsconfig-paths'; import viteTsconfigPaths from 'vite-tsconfig-paths';
import zlib from 'zlib'; import zlib from 'zlib';
@@ -11,6 +10,29 @@ import zlib from 'zlib';
// @ts-expect-error - mock server doesn't have type declarations // @ts-expect-error - mock server doesn't have type declarations
import mockServer from '../mock-api/mockServer.js'; 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 // Plugin to display bundle size information
const bundleSizeReporter = (): Plugin => { const bundleSizeReporter = (): Plugin => {
return { return {
@@ -18,144 +40,111 @@ const bundleSizeReporter = (): Plugin => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
writeBundle(options: any, bundle: any) { writeBundle(options: any, bundle: any) {
console.log('\n📦 Bundle Size Report:'); console.log('\n📦 Bundle Size Report:');
console.log('='.repeat(50)); console.log(REPEAT_CHAR.repeat(REPEAT_COUNT));
let totalSize = 0; const files: BundleFile[] = [];
const files: Array<{ name: string; size: number; gzipSize?: number }> = []; const outDir = options.dir || DEFAULT_OUT_DIR;
for (const [fileName, chunk] of Object.entries(
bundle as Record<string, unknown>
)) {
// 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;
// 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 { try {
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
size = stats.size; const size = stats.size;
totalSize += size;
// Calculate gzip size
const fileContent = fs.readFileSync(filePath); const fileContent = fs.readFileSync(filePath);
gzipSize = zlib.gzipSync(fileContent).length; const gzipSize = zlib.gzipSync(fileContent).length;
files.push({ files.push({ name: fileName, size, gzipSize });
name: fileName,
size,
gzipSize
});
} catch (error) { } catch (error) {
console.warn(`Could not read file ${fileName}:`, error); console.warn(`Could not read file ${fileName}:`, error);
} }
} }
} }
// Sort files by size (largest first)
files.sort((a, b) => b.size - a.size); files.sort((a, b) => b.size - a.size);
// Display individual file sizes
files.forEach((file) => { files.forEach((file) => {
const sizeKB = (file.size / 1024).toFixed(2); const sizeKB = (file.size / KB_DIVISOR).toFixed(2);
const gzipKB = file.gzipSize ? (file.gzipSize / 1024).toFixed(2) : 'N/A'; const gzipKB = (file.gzipSize / KB_DIVISOR).toFixed(2);
console.log( console.log(
`📄 ${file.name.padEnd(30)} ${sizeKB.padStart(8)} KB (${gzipKB} KB gzipped)` `📄 ${file.name.padEnd(30)} ${sizeKB.padStart(8)} KB (${gzipKB} KB gzipped)`
); );
}); });
console.log('='.repeat(50)); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
console.log(`📊 Total Bundle Size: ${(totalSize / 1024).toFixed(2)} KB`); const totalGzipSize = files.reduce((sum, file) => sum + file.gzipSize, 0);
const compressionRatio = ((totalSize - totalGzipSize) / totalSize) * 100;
// Calculate and display gzip total console.log(REPEAT_CHAR.repeat(REPEAT_COUNT));
const totalGzipSize = files.reduce( console.log(`📊 Total Bundle Size: ${(totalSize / KB_DIVISOR).toFixed(2)} KB`);
(sum, file) => sum + (file.gzipSize || 0), console.log(`🗜️ Total Gzipped Size: ${(totalGzipSize / KB_DIVISOR).toFixed(2)} KB`);
0 console.log(`📈 Compression Ratio: ${compressionRatio.toFixed(1)}%`);
); console.log(REPEAT_CHAR.repeat(REPEAT_COUNT));
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( // Common preact plugin config
({ command, mode }: { command: string; mode: string }) => { const createPreactPlugin = (devToolsEnabled: boolean) =>
if (command === 'serve') {
console.log('Preparing for standalone build with server, mode=' + mode);
return {
plugins: [
preact({ preact({
// Keep dev tools enabled for development devToolsEnabled,
devToolsEnabled: true,
prefreshEnabled: false prefreshEnabled: false
}), });
viteTsconfigPaths(),
bundleSizeReporter(), // Add bundle size reporting
mockServer()
],
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime'
}
},
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') { // Common base plugins
console.log('Preparing for hosted build'); const createBasePlugins = (devToolsEnabled: boolean, includeBundleReporter = true) => {
return { const plugins = [
plugins: [ createPreactPlugin(devToolsEnabled),
preact({ viteTsconfigPaths()
// Enable Preact optimizations for hosted build ];
devToolsEnabled: false, if (includeBundleReporter) {
prefreshEnabled: false plugins.push(bundleSizeReporter());
}),
viteTsconfigPaths(),
bundleSizeReporter() // Add bundle size reporting
],
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime'
} }
}, return plugins;
build: { };
target: 'es2020',
chunkSizeWarningLimit: 512, // Manual chunk splitting strategy
minify: 'terser', 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, cssMinify: true,
assetsInlineLimit: 4096, assetsInlineLimit: ASSETS_INLINE_LIMIT
terserOptions: { });
// Terser options for hosted builds
const createHostedTerserOptions = () => ({
compress: { compress: {
passes: 3, passes: 3,
drop_console: true, drop_console: true,
@@ -166,40 +155,34 @@ export default defineConfig(
mangle: { mangle: {
toplevel: true toplevel: true
}, },
ecma: 2020 ecma: 2020 as const
}, });
rollupOptions: {
treeshake: {
moduleSideEffects: false
},
output: {
manualChunks(id: string) {
if (id.includes('node_modules')) {
if (id.includes('preact')) {
return '@preact';
}
return 'vendor';
}
return undefined;
}
}
}
}
};
}
console.log('Preparing for production, optimized build'); // 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
});
return { // Image optimization plugin
plugins: [ const imageOptimizationPlugin = {
preact({
// Enable Preact optimizations
devToolsEnabled: false,
prefreshEnabled: false
}),
viteTsconfigPaths(),
// Enable image optimization for size reduction
{
...viteImagemin({ ...viteImagemin({
verbose: false, verbose: false,
gifsicle: { gifsicle: {
@@ -218,92 +201,92 @@ export default defineConfig(
}, },
svgo: { svgo: {
plugins: [ plugins: [
{ { name: 'removeViewBox' },
name: 'removeViewBox' { name: 'removeEmptyAttrs', active: false }
},
{
name: 'removeEmptyAttrs',
active: false
}
] ]
} }
}), }),
enforce: 'pre' 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}`);
return {
plugins: [
...createBasePlugins(true, true),
mockServer()
],
resolve: {
alias: RESOLVE_ALIASES
}, },
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'
}
},
build: {
target: ES_TARGET,
minify: false,
sourcemap: true
}
};
}
if (mode === 'hosted') {
console.log('Preparing for hosted build');
return {
plugins: createBasePlugins(false, true),
resolve: {
alias: RESOLVE_ALIASES
},
build: {
...createBaseBuildConfig(),
minify: 'terser' as const,
terserOptions: createHostedTerserOptions(),
rollupOptions: {
treeshake: {
moduleSideEffects: false
},
output: {
manualChunks: createManualChunks(false)
}
}
}
};
}
console.log('Preparing for production, optimized build');
return {
plugins: [
...createBasePlugins(false, true),
imageOptimizationPlugin,
visualizer({ visualizer({
template: 'treemap', // or sunburst template: 'treemap',
open: false, open: false,
gzipSize: true, gzipSize: true,
brotliSize: true, brotliSize: true,
filename: '../analyse.html' // will be saved in project's root filename: '../analyse.html'
}), })
bundleSizeReporter() // Add bundle size reporting
], ],
resolve: { resolve: {
alias: { alias: RESOLVE_ALIASES
react: 'preact/compat',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime'
}
}, },
build: { build: {
// Target modern browsers for smaller bundles ...createBaseBuildConfig(),
target: 'es2020', minify: 'terser' as const,
chunkSizeWarningLimit: 512, terserOptions: createProductionTerserOptions(),
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
},
rollupOptions: { rollupOptions: {
// Enable aggressive tree shaking
treeshake: { treeshake: {
moduleSideEffects: false, moduleSideEffects: false,
propertyReadSideEffects: false, propertyReadSideEffects: false,
@@ -311,65 +294,11 @@ export default defineConfig(
unknownGlobalSideEffects: false unknownGlobalSideEffects: false
}, },
output: { output: {
// Optimize chunk naming for better caching
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js', entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]', assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks(id: string) { manualChunks: createManualChunks(true),
if (id.includes('node_modules')) { sourcemap: false
// 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
} }
} }
} }

View File

@@ -19,47 +19,49 @@ ESP32React::ESP32React(AsyncWebServer * server, FS * fs)
// Serve static web resources // Serve static web resources
// //
// Populate the last modification date based on build datetime ArRequestHandlerFunction indexHtmlHandler = nullptr;
static char last_modified[50];
sprintf(last_modified, "%s %s CET", __DATE__, __TIME__);
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) { 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) // 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) { if (request->header("If-None-Match").equals(hash)) {
return request->send(304); response = request->beginResponse(304);
} else if (request->header("If-None-Match").equals(hash)) { } else {
return request->send(304); 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); // always send these headers - see https://datatracker.ietf.org/doc/html/rfc7232#section-4.1
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);
response->addHeader("ETag", hash); response->addHeader("ETag", hash);
response->addHeader("Cache-Control", "no-cache"); // Requires revalidation before using cached content (ETags enable 304 responses)
request->send(response); request->send(response);
}; };
server->on(uri, HTTP_GET, requestHandler); server->on(uri, HTTP_GET, requestHandler);
// 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" // Serving non matching get requests with "/index.html"
// OPTIONS get a straight up 200 response // OPTIONS get a straight up 200 response
if (strncmp(uri, "/index.html", 11) == 0) { if (indexHtmlHandler != nullptr) {
server->onNotFound([requestHandler](AsyncWebServerRequest * request) { server->onNotFound([indexHtmlHandler](AsyncWebServerRequest * request) {
if (request->method() == HTTP_GET) { if (request->method() == HTTP_GET) {
requestHandler(request); indexHtmlHandler(request);
} else if (request->method() == HTTP_OPTIONS) { } else if (request->method() == HTTP_OPTIONS) {
request->send(200); request->send(200);
} else { } else {
request->send(404); request->send(404); // not found
} }
}); });
} }
});
} }
void ESP32React::begin() { void ESP32React::begin() {