====== HomeCtrl: Considering compression with Brotli ====== {{tag>dev cpp wasm webassembly homectrl qt}} Allright guys, as mentioned in my previous article, I should now find how to deal with brotli decompression for my wasm file when downloading that from the server. Let's rock it baby 😆! ====== ====== Youtube video for this article available at: ;#; {{ youtube>rRtUl3ZmGJQ?large }} ;#; My wasm file is currently 11MB large, not really unmanageable, but still, I've seen it was recommended from everywhere to compress those files with brotli, so, where do we start ? First of course I tried a simple command to compress this WASM file: brotli: notify: false cmd: ${NVP_ROOT_DIR}/libraries/windows_msvc/brotli-git-1.0.9/bin/brotli.exe Then calling this script as: D:\Projects\NervLand\dist\webapps\homeview>nvp brotli -k -Z homeview.wasm This will produce the homectrl.wasm.br file, of size of about **3.59MB** which is not bad compared to 11.92MB (that's just about 30% of the full size). Now problem with the command above is that it depends on the brotli executable I built myself, so that's platform dependent. I think I could rather use the brotli package in python directly: let's try to write a very simple command for that. Adding the **brenc** script: brenc: notify: false custom_python_env: brotli_env cmd: ${PYTHON} ${PROJECT_ROOT_DIR}/nvp/admin/brotli_handler.py encode python_path: ["${NVP_ROOT_DIR}"] And preparing a skeleton component for brotli: class BrotliHandler(NVPComponent): """BrotliHandler component class""" def __init__(self, ctx: NVPContext): """Component constructor""" NVPComponent.__init__(self, ctx) def process_cmd_path(self, cmd): """Re-implementation of process_cmd_path""" if cmd == "encode": file = self.get_param("input_file") logger.info("Shoulc compress file %s", file) return True return False if __name__ == "__main__": # Create the context: context = NVPContext() # Add our component: comp = context.register_component("BrotliHandler", BrotliHandler(context)) psr = context.build_parser("encode") psr.add_str("input_file")("File to compress") comp.run() Now let's put some meat in this component. Here are the ''compress()'' and ''decompress()'' implemnetations: def compress_file(self, input_file, output_file=None): """Compress a file""" if output_file is None: output_file = input_file + ".br" params = { # 'mode': brotli.MODE_TEXT # Set to brotli.MODE_TEXT for text-based files "mode": brotli.MODE_GENERIC, "quality": 11, "lgwin": 22, "lgblock": 0, } start_time = time.time() content = self.read_binary_file(input_file) logger.info("Compressing %s...", input_file) compressed = brotli.compress(content, **params) # write the compressed data: self.write_binary_file(compressed, output_file) elapsed = time.time() - start_time logger.info("Compressed %s in %.2fsecs", input_file, elapsed) return True def decompress_file(self, input_file, output_file=None): """Compress a file""" if output_file is None: output_file = self.set_path_extension(input_file, "") start_time = time.time() content = self.read_binary_file(input_file) logger.info("Decompressing %s...", input_file) decompressed = brotli.decompress(content) # write the compressed data: self.write_binary_file(decompressed, output_file) elapsed = time.time() - start_time logger.info("Decompressed %s in %.2fsecs", input_file, elapsed) return True I just tested that on my homeview.wasm file and it works just fine: D:\Projects\NervLand\dist\webapps\homeview>nvp brenc homeview.wasm 2023/07/13 15:52:54 [nvp.nvp_compiler] INFO: MSVC root dir is: D:\Softs\VisualStudio\VS2022 2023/07/13 15:52:54 [nvp.nvp_compiler] INFO: Found msvc-14.34.31933 2023/07/13 15:52:54 [nvp.core.build_manager] INFO: Selecting compiler msvc-14.34.31933 (in D:\Softs\VisualStudio\VS2022) 2023/07/13 15:52:54 [__main__] INFO: Compressing homeview.wasm... 2023/07/13 15:53:18 [__main__] INFO: Compressed homeview.wasm in 23.55secs D:\Projects\NervLand\dist\webapps\homeview>nvp brdec homeview.wasm.br -o home.wasm 2023/07/13 15:54:54 [nvp.nvp_compiler] INFO: MSVC root dir is: D:\Softs\VisualStudio\VS2022 2023/07/13 15:54:54 [nvp.nvp_compiler] INFO: Found msvc-14.34.31933 2023/07/13 15:54:54 [nvp.core.build_manager] INFO: Selecting compiler msvc-14.34.31933 (in D:\Softs\VisualStudio\VS2022) 2023/07/13 15:54:54 [__main__] INFO: Decompressing homeview.wasm.br... 2023/07/13 15:54:54 [__main__] INFO: Decompressed homeview.wasm.br in 0.05secs Next I need to somehow call this compressor when I'm about to serve my app in firefox. How should I proceed 🤔? I guess I should check if the .wasm file is more recent than the .wasm.br file ? This should be handled directly in my **https_server.py** component. I'm now adding the following code at the beginning of the ''HttpsServer.serve_directory'' function: # Check if we have .wasm files in this folder: wasm_files = self.get_all_files(root_dir, exp=r"\.wasm$", recursive=False) for wfile in wasm_files: in_file = self.get_path(root_dir, wfile) logger.info("Found WASM file %s", in_file) # check if we have a corresponding wasm.br file: brfile = in_file + ".br" if not self.file_exists(brfile): logger.info("Generating %s...", brfile) brotli = self.get_component("brotli") brotli.compress_file(in_file, brfile) This also means that I have to update the python environment used to run the HttpsServer command to include the brotli package of course OK, this is working fine (provided I remove the existing .wasm.br file manually here): PS D:\Projects\NervHome> nvp nvl_serve_homeview 2023/07/13 16:21:26 [nvp.nvp_compiler] INFO: MSVC root dir is: D:\Softs\VisualStudio\VS2022 2023/07/13 16:21:26 [nvp.nvp_compiler] INFO: Found msvc-14.34.31933 2023/07/13 16:21:26 [nvp.core.build_manager] INFO: Selecting compiler msvc-14.34.31933 (in D:\Softs\VisualStudio\VS2022) 2023/07/13 16:21:26 [__main__] INFO: Serving directory D:\Projects\NervLand/dist/webapps/homeview... 2023/07/13 16:21:26 [__main__] INFO: Found WASM file D:\Projects\NervLand/dist/webapps/homeview\homeview.wasm 2023/07/13 16:21:26 [__main__] INFO: Generating D:\Projects\NervLand/dist/webapps/homeview\homeview.wasm.br... 2023/07/13 16:21:26 [nvp.admin.brotli_handler] INFO: Compressing D:\Projects\NervLand/dist/webapps/homeview\homeview.wasm... 2023/07/13 16:21:50 [nvp.admin.brotli_handler] INFO: Compressed D:\Projects\NervLand/dist/webapps/homeview\homeview.wasm in 23.70secs 2023/07/13 16:21:50 [__main__] INFO: Serving at https://nervtech.local:444/homeview.html For the second part now we need to check the modification time of both files if the .br file already exists, so here is the final code: # Check if we have .wasm files in this folder: wasm_files = self.get_all_files(root_dir, exp=r"\.wasm$", recursive=False) for wfile in wasm_files: in_file = self.get_path(root_dir, wfile) logger.info("Found WASM file %s", in_file) # check if we have a corresponding wasm.br file: brfile = in_file + ".br" if not self.file_exists(brfile): logger.info("Generating %s...", brfile) brotli = self.get_component("brotli") brotli.compress_file(in_file, brfile) elif self.get_file_mtime(wfile) > self.get_file_mtime(brfile): # The brfile already exists but the wasm file is more recent logger.info("Updating %s...", brfile) self.remove_file(brfile) brotli = self.get_component("brotli") brotli.compress_file(in_file, brfile) else: logger.info("%s is OK", brfile) ==== Trying to find a javascript version of brotli ==== Now another fun part: I will need brotli in the browser to do the decoding, but I can't seem to be able to find just a simple brotli.min.js file anywhere online, come on guys... 🤣 I keep searching... Feewww, finally found [[https://github.com/google/brotli/blob/master/js|this location]] which I think could be what I'm after. OF course, then I had to fight a little more to figure out how to earth one can start using the **decode.js** file provided in that folder without first setting up a full nodejs environment to build/bundle that as a working module etc etc. But eventually I found an example of how to do it, which turns to be super easy: => if we use the type "module" for a script, then we can use the **import {xxx}** syntax in there! (I didn't know that lol) (I found this example on [[https://github.com/google/brotli/issues/881|this page]] by the way). Running the app with this code in place will produce the following outputs in the javascript console, indicating that the decompression is indeed working as expected: The lyrics are: ukko nooa, ukko nooa oli kunnon mies, kun han meni saunaan, pisti laukun naulaan, ukko nooa, ukko nooa oli kunnon mies. Done printing lyrics. I have zero what the lyrics language is here, but since it's not totally random/garbage, it's certainly a real language :-) On the page https://github.com/google/brotli/blob/master/js we can find both **decode.js** and **decode.min.js**, I've tried both, and both are working just fine. Of course for production the min.js version should be used, but in case of errors it mgiht help to use the non minified version sometimes. ==== Decompressing our homeview.wasm.br file ==== With this Brotli decompressor read, I moved to the next step: trying to actually put that to use in the **qtloade.js** script to download/decompress the homeview.wasm.br file. To achieve this, the easiest option from my perspective was to replace the ''fetchCompileWasm()'' function which is called only to download compile the wasm file, with a customized ''fetchCompileCompressedWasm()'' function: // Fetch and compile wasm module var wasmModule = undefined; // var wasmModulePromise = fetchCompileWasm(applicationName + ".wasm").then(function (module) { var wasmModulePromise = fetchCompileCompressedWasm(applicationName + ".wasm").then(function (module) { wasmModule = module; }); And the new function is as follow: function fetchCompileCompressedWasm(filePath) { console.log("Fetching resource: "+filePath) // return fetchResource(filePath) return fetchResource(filePath+".br") .then(function(response) { // Content is Brotli-encoded, decode it return response.arrayBuffer().then(function(buffer) { console.log("Trying to decode buffer of size "+ buffer.byteLength) var decodedBuffer = BrotliDecode(new Uint8Array(buffer)); console.log("Decoded buffer of size "+ decodedBuffer.length) return new Response(decodedBuffer.buffer, { status: response.status, statusText: response.statusText }); }); }) .then(function(response) { // if (typeof WebAssembly.compileStreaming !== "undefined") { // self.loaderSubState = "Downloading/Compiling"; // setStatus("Loading"); // return WebAssembly.compileStreaming(response).catch(function(error) { // // compileStreaming may/will fail if the server does not set the correct // // mime type (application/wasm) for the wasm file. Fall back to fetch, // // then compile in this case. // return fetchThenCompileWasm(response); // }); // } else { // Fall back to fetch, then compile if compileStreaming is not supported return fetchThenCompileWasm(response); // } }); } In the code above, I don't think I can keep using the ''compileStreaming()'' path, as anyway, I need to download the full response data before I can decompress it with brotli, this might be a problem for very large files, but for now, this will be good enough. And this works 🥳! {{ blog:2023:018_homeview_brotli_decompress.png }} ==== Can we get progress report while downloading the WASM file ? ==== Allright, next step now: could we reprot some process percentage while downloading the compressed wasm file ? Let's see... Asking our dear friend ChatGPT about this, and he said we need something like this (which sounds quite appropriate to me): return fetch(fullPath) .then(function (response) { if (!response.ok) { let err = response.status + " " + response.statusText + " " + response.url; handleError(err); return Promise.reject(err); } else { // Start reading the response as a stream const reader = response.body.getReader(); const contentLength = +response.headers.get('Content-Length'); let receivedBytes = 0; // Define a function to handle each chunk of data function handleData({ done, value }) { if (done) { // All data has been received return response; } // Process the chunk of data here receivedBytes += value.length; const progress = (receivedBytes / contentLength) * 100; reportProgress(progress); // Read the next chunk of data return reader.read().then(handleData); } // Start reading the data return reader.read().then(handleData); } }); Eventually this path might even help us to restore the streamed WASM compilation support 🤔, but let's not get too excited about this lol hmmm... 🤔, actually, I'm not sure I can retrieve the downloaded data with the code above, so an alternative here is to keep all the downloaded "chunks", and then create a blob out of them, so with something like that: const contentLength = +response.headers.get('Content-Length'); const reader = response.body.getReader(); let receivedBytes = 0; let chunks = []; function readChunk() { return reader.read().then(function ({ done, value }) { if (done) { // All data has been downloaded return chunks; } chunks.push(value); receivedBytes += value.length; const progress = (receivedBytes / contentLength) * 100; progressCallback(progress); return readChunk(); // Read the next chunk }); } return readChunk(); And then: .then(function (chunks) { // All data has been downloaded const blob = new Blob(chunks); // Process the downloaded blob as needed console.log("Download completed"); }) And from that blob we could retrieve an **arrayBuffer**, right ? Yes indeed, this is working just fine once more 🥳! I can see the progress reported for a faction of a secs since i'm on local network and it's support fast anyway, but later when I deploy the app on my public website, this will give a better user experience for sure. ==== Showtime: Deploying the app on nervtech.org ==== Okay, time to make a leap of faith now: let's try to deploy the app directly on nervtech.org 😳🤣 Adding the required location in the nervtech.org site config: location /homeview { root /mnt/data1/web/homeview; try_files $uri $uri/ /homeview.html; } And then restarting the nginx server as usual and crossing fingers 🤞... But of course... not working 🤣: it seems I have a problem with the MIME type of my served files 🤔: {{ blog:2023:019_homeview_wrong_mimes.png }} Checking the nginx config is OK: $ dk_enter nginx_server root@de35904c66d4:/# nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful Okay, what else could be wrong here? [//...burning some neurons on this...//] hmmm, allright, not really sure what this was, but I finally got it working with this location definition: location /homeview { alias /mnt/data1/web/homeview/; index homeview.html; try_files $uri $uri/ /homeview.html; } With that change, it works very well on desktop 👍! But then, trying on my mobile phone I get an exception when trying to allocate the memory for the app 😞. Now, if I remember correctly, when building QT for webassembly apps I have requested 1GB of mem at start to be able to use the multi threading feature from emscripten: maybe I could reduce this a bit ? Let's try with something like 256MB maybe. Here is the updated macro in cmake, preparing support to customize the initial memory value: macro(get_emscripten_qt_linkflags link_fvar) # Check if initial memory is provided: if(NOT NV_WASM_INIT_MEM) set(NV_WASM_INIT_MEM "256MB") endif() # -s ALLOW_MEMORY_GROWTH=1 -s USE_ZLIB=1 -s USE_HARFBUZZ=1 -s USE_FREETYPE=1 # -s USE_LIBPNG=1 -s DISABLE_EXCEPTION_CATCHING=1 set(${link_fvar} "${${link_fvar}} -s WASM=1 -O2 --bind -pthread -sPTHREAD_POOL_SIZE=4 -s ASSERTIONS " ) set(${link_fvar} "${${link_fvar}} -s FETCH=1 -s WASM_BIGINT=1 -s MODULARIZE=1 -s EXPORT_NAME=createQtAppInstance" ) set(${link_fvar} "${${link_fvar}} -s EXPORTED_RUNTIME_METHODS=UTF16ToString,stringToUTF16 -s INITIAL_MEMORY=${NV_WASM_INIT_MEM}" ) set(${link_fvar} "${${link_fvar}} -s FULL_ES2=1 -s USE_WEBGL2=1 -s NO_EXIT_RUNTIME=1 -s ERROR_ON_UNDEFINED_SYMBOLS=1" ) set(${link_fvar} "${${link_fvar}} -lidbfs.js -s FORCE_FILESYSTEM=1") # Might need to include "--use-preload-plugins" here. cf. # https://emscripten.org/docs/tools_reference/emcc.html?highlight=preload endmacro() And building with this change, the app is finally working on my android phone too in chrome! Yeeepeee 🥳! Credentials are remembered and all, so all good so far! Next I think I should spend some time refactoring a bit the design of this app to make it more convinient to use from a phone screen, but that will be for another session. meanwhile, happy coding and see ya next time ;-)! /* https://www.afasterweb.com/2016/03/15/serving-up-brotli-with-nginx-and-jekyll/ TODO: * how to trigger an event in C++ when done with the IDBFS loading part ? */