Created
August 4, 2025 17:16
-
-
Save animeshjain/6da1152e3636c2b69cbc27d79ba54b54 to your computer and use it in GitHub Desktop.
Content-Hashed Caching for Flutter Web (Without a Service Worker)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Post-build script for Flutter Web | |
| # Generates production patch loader with content-based hashes | |
| set -e | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| APP_ROOT="/path/to/your_app" | |
| echo "Flutter Web Post-Build Script" | |
| echo "============================" | |
| # Check if build directory exists | |
| if [ ! -d "$APP_ROOT/build/web" ]; then | |
| echo "Error: Build directory not found at $APP_ROOT/build/web" | |
| echo "Please run 'flutter build web' first" | |
| exit 1 | |
| fi | |
| # Run the asset hash generator | |
| echo "Generating asset hashes..." | |
| "$SCRIPT_DIR/generate-asset-hashes.sh" | |
| # Update index.html to use production patch loader | |
| INDEX_HTML="$APP_ROOT/build/web/index.html" | |
| echo "" | |
| echo "Updating index.html..." | |
| # The production loader is already generated in build/web by generate-asset-hashes.sh | |
| # No need to copy it | |
| # Add timestamp to production patch loader to ensure it's always fetched fresh | |
| if [ -f "$INDEX_HTML" ]; then | |
| # Generate timestamp | |
| TIMESTAMP=$(date +%s) | |
| # Add timestamp to production-patch-loader.js reference | |
| if grep -q "production-patch-loader.js" "$INDEX_HTML"; then | |
| # Replace production-patch-loader.js with production-patch-loader.js?t=timestamp | |
| sed -i '' "s|production-patch-loader\.js\"|production-patch-loader.js?t=$TIMESTAMP\"|g" "$INDEX_HTML" | |
| echo "Added timestamp to production patch loader: ?t=$TIMESTAMP" | |
| else | |
| echo "Warning: Production patch loader not found in index.html" | |
| echo "Please add: <script src=\"production-patch-loader.js\"></script>" | |
| fi | |
| fi | |
| echo "" | |
| echo "Post-build complete!" | |
| echo "" | |
| echo "Production build is ready with content-based cache busting." | |
| echo "Files will only be reloaded when their content changes." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Generate MD5 hashes for all JS and WASM files in the build directory | |
| # Updates the production patch loader template with content hashes | |
| set -e | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| PROJECT_ROOT="/home/projects" | |
| BUILD_DIR="$PROJECT_ROOT/your_app/build/web" | |
| TEMPLATE_FILE="$PROJECT_ROOT/your_app/web/production-patch-loader.js" | |
| OUTPUT_FILE="$PROJECT_ROOT/your_app/build/web/production-patch-loader.js" | |
| echo "Flutter Web Asset Hash Generator" | |
| echo "================================" | |
| # Check if build directory exists | |
| if [ ! -d "$BUILD_DIR" ]; then | |
| echo "Error: Build directory not found: $BUILD_DIR" | |
| echo "Please run 'flutter build web' first." | |
| exit 1 | |
| fi | |
| # Check if template exists | |
| if [ ! -f "$TEMPLATE_FILE" ]; then | |
| echo "Error: Template file not found: $TEMPLATE_FILE" | |
| exit 1 | |
| fi | |
| # Build the asset map | |
| echo "Generating asset map..." | |
| echo "" | |
| ASSET_MAP_CONTENT=" const ASSET_MAP = {" | |
| # Find and hash files | |
| FILE_COUNT=0 | |
| while IFS= read -r -d '' file; do | |
| # Get relative path from build directory | |
| REL_PATH="${file#$BUILD_DIR/}" | |
| # Calculate MD5 hash (first 12 characters) | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| # macOS | |
| HASH=$(md5 -q "$file" | cut -c1-12) | |
| else | |
| # Linux | |
| HASH=$(md5sum "$file" | cut -c1-12) | |
| fi | |
| echo " $REL_PATH -> $HASH" | |
| # Add to asset map | |
| if [ $FILE_COUNT -gt 0 ]; then | |
| ASSET_MAP_CONTENT="$ASSET_MAP_CONTENT," | |
| fi | |
| ASSET_MAP_CONTENT="$ASSET_MAP_CONTENT | |
| '$REL_PATH': '$HASH'" | |
| ((FILE_COUNT++)) | |
| done < <(find "$BUILD_DIR" -type f \( -name "*.js" -o -name "*.mjs" -o -name "*.cjs" -o -name "*.wasm" \) -print0) | |
| ASSET_MAP_CONTENT="$ASSET_MAP_CONTENT | |
| };" | |
| echo "" | |
| echo "Found $FILE_COUNT files" | |
| echo "Updating production patch loader..." | |
| # Copy template to output | |
| cp "$TEMPLATE_FILE" "$OUTPUT_FILE" | |
| # Create a temporary file with the asset map content | |
| TEMP_MAP_FILE=$(mktemp) | |
| echo "$ASSET_MAP_CONTENT" > "$TEMP_MAP_FILE" | |
| # Use sed to replace the empty ASSET_MAP with the generated one | |
| # This approach handles multi-line content better than awk | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| # macOS sed requires different syntax | |
| sed -i '' "/^ const ASSET_MAP = {};$/r $TEMP_MAP_FILE" "$OUTPUT_FILE" | |
| sed -i '' "/^ const ASSET_MAP = {};$/d" "$OUTPUT_FILE" | |
| else | |
| # Linux sed | |
| sed -i "/^ const ASSET_MAP = {};$/r $TEMP_MAP_FILE" "$OUTPUT_FILE" | |
| sed -i "/^ const ASSET_MAP = {};$/d" "$OUTPUT_FILE" | |
| fi | |
| # Clean up temp file | |
| rm -f "$TEMP_MAP_FILE" | |
| echo "" | |
| echo "Production patch loader generated: $OUTPUT_FILE" | |
| echo "" | |
| echo "Asset map contains $FILE_COUNT files with content-based hashes" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Production Patch Loader - Content-based Cache Busting | |
| * | |
| * Inspired by Discord Embed SDK's URL patching approach, this script intercepts | |
| * all JavaScript and WebAssembly file requests to add cache-busting query parameters. | |
| * Uses MD5 hashes of file contents for cache busting, ensuring files are only | |
| * reloaded when their content actually changes. | |
| * | |
| * Key Features: | |
| * - Only patches same-origin URLs to avoid affecting external resources | |
| * - Comprehensive coverage of all modern web loading mechanisms | |
| * - Handles Flutter's Trusted Types security policies | |
| * - Monitors DOM mutations for dynamically added scripts | |
| * - Content-based hashing for efficient caching | |
| * | |
| * This file is auto-updated by generate-asset-hashes.sh during build | |
| * The ASSET_MAP will be replaced with actual MD5 hashes | |
| * | |
| * @see https://github.com/discord/embedded-app-sdk | |
| */ | |
| (function () { | |
| 'use strict'; | |
| // Configuration constants | |
| const PARAM_NAME = 'v'; | |
| const JS_WASM_RE = /\.(?:m?js|cjs|wasm)(?:$|[?#])/i; // Matches .js, .mjs, .cjs, .wasm files | |
| // Auto-generated asset map with MD5 hashes | |
| // This will be replaced during build process | |
| const ASSET_MAP = {}; | |
| // Store original browser APIs before patching | |
| const originals = { | |
| fetch: window.fetch.bind(window), | |
| xhrOpen: XMLHttpRequest.prototype.open, | |
| Worker: window.Worker, | |
| SharedWorker: window.SharedWorker, | |
| swRegister: navigator.serviceWorker?.register?.bind(navigator.serviceWorker), | |
| }; | |
| /** | |
| * Get hash for a given file path from the asset map | |
| * @param {string} url - URL to look up in asset map | |
| * @returns {string|null} Hash value or null if not found | |
| */ | |
| function getFileHash(url) { | |
| try { | |
| const urlObj = new URL(url, window.location.href); | |
| const pathname = urlObj.pathname; | |
| // Try exact match first | |
| const relativePath = pathname.startsWith('/') ? pathname.substring(1) : pathname; | |
| if (ASSET_MAP[relativePath]) { | |
| return ASSET_MAP[relativePath]; | |
| } | |
| // Try without leading slash variations | |
| for (const [path, hash] of Object.entries(ASSET_MAP)) { | |
| if (pathname.endsWith(path) || pathname.endsWith('/' + path)) { | |
| return hash; | |
| } | |
| } | |
| return null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Converts any URL input to an absolute URL object | |
| * @param {string|URL} input - URL to convert | |
| * @returns {URL|null} Absolute URL or null if parsing fails | |
| */ | |
| function toAbsURL(input) { | |
| try { | |
| return input instanceof URL | |
| ? new URL(input.toString()) | |
| : new URL(String(input), document.baseURI || window.location.href); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Checks if URL uses HTTP or HTTPS protocol | |
| * @param {URL} u - URL to check | |
| * @returns {boolean} True if HTTP/HTTPS | |
| */ | |
| function isHttpLike(u) { | |
| return u && (u.protocol === 'http:' || u.protocol === 'https:'); | |
| } | |
| /** | |
| * Performs strict same-origin check (protocol + host + port) | |
| * @param {URL} u - URL to check | |
| * @returns {boolean} True if same origin as current page | |
| */ | |
| function isSameOrigin(u) { | |
| return isHttpLike(u) && u.origin === window.location.origin; | |
| } | |
| /** | |
| * Determines if URL should have cache-busting parameter added | |
| * Only applies to same-origin JavaScript and WebAssembly files that have hashes | |
| * @param {URL} u - URL to check | |
| * @returns {boolean} True if cache busting should be applied | |
| */ | |
| function shouldCacheBust(u) { | |
| return isSameOrigin(u) && | |
| JS_WASM_RE.test(u.pathname) && | |
| getFileHash(u.href) !== null; | |
| } | |
| /** | |
| * Core patching logic - adds cache-busting parameter if needed | |
| * @param {string|URL} input - URL to potentially patch | |
| * @returns {string|URL} Original or patched URL (maintains input type) | |
| */ | |
| function maybePatchUrl(input) { | |
| const u = toAbsURL(input); | |
| if (!u || !shouldCacheBust(u)) return input; // Skip non-same-origin or non-JS/WASM | |
| const hash = getFileHash(u.href); | |
| if (hash) { | |
| u.searchParams.set(PARAM_NAME, hash); // Add/overwrite cache-bust parameter | |
| } | |
| return input instanceof URL ? u : u.toString(); // Preserve original type | |
| } | |
| // ===== PATCHING BROWSER APIs ===== | |
| /** | |
| * Patch fetch() API to intercept all network requests | |
| * Handles both simple URLs and Request objects | |
| */ | |
| window.fetch = function (input, init) { | |
| if (input instanceof Request) { | |
| const newUrl = maybePatchUrl(input.url); | |
| if (newUrl === input.url) return originals.fetch(input, init); | |
| // Rebuild Request conservatively to preserve all properties | |
| // This approach follows Discord's pattern to avoid breaking streaming/duplex requests | |
| return (async () => { | |
| let body; | |
| try { | |
| const method = input.method?.toUpperCase?.() || 'GET'; | |
| if (method !== 'GET' && method !== 'HEAD') { | |
| // Clone the body stream to a Blob so we can reuse it safely | |
| body = await input.blob(); | |
| } | |
| } catch { /* best effort */ } | |
| const rebuilt = new Request(newUrl, { | |
| method: input.method, | |
| headers: input.headers, | |
| body, | |
| mode: input.mode, | |
| credentials: input.credentials, | |
| cache: input.cache, | |
| redirect: input.redirect, | |
| referrer: input.referrer, | |
| referrerPolicy: input.referrerPolicy, | |
| integrity: input.integrity, | |
| keepalive: input.keepalive, | |
| signal: input.signal, | |
| // @ts-ignore: some browsers expose duplex | |
| duplex: input.duplex | |
| }); | |
| // Allow caller's init to override if provided | |
| return originals.fetch(rebuilt, init); | |
| })(); | |
| } | |
| if (typeof input === 'string' || input instanceof URL) { | |
| return originals.fetch(maybePatchUrl(input), init); | |
| } | |
| return originals.fetch(input, init); | |
| }; | |
| /** | |
| * Patch XMLHttpRequest for legacy AJAX calls | |
| */ | |
| XMLHttpRequest.prototype.open = function (method, url, ...rest) { | |
| try { url = maybePatchUrl(url); } catch {} | |
| return originals.xhrOpen.call(this, method, url, ...rest); | |
| }; | |
| /** | |
| * Patch Web Workers and SharedWorkers | |
| * Uses Proxy to intercept constructor calls | |
| */ | |
| if (originals.Worker) { | |
| window.Worker = new Proxy(originals.Worker, { | |
| construct(target, args) { | |
| if (args && args[0]) args[0] = maybePatchUrl(args[0]); | |
| return new target(...args); | |
| } | |
| }); | |
| } | |
| if (originals.SharedWorker) { | |
| window.SharedWorker = new Proxy(originals.SharedWorker, { | |
| construct(target, args) { | |
| if (args && args[0]) args[0] = maybePatchUrl(args[0]); | |
| return new target(...args); | |
| } | |
| }); | |
| } | |
| /** | |
| * Patch Service Worker registration | |
| * Ensures service worker scripts get cache-busted too | |
| */ | |
| if (originals.swRegister) { | |
| navigator.serviceWorker.register = function (scriptURL, options) { | |
| return originals.swRegister(maybePatchUrl(scriptURL), options); | |
| }; | |
| } | |
| /** | |
| * Patch CSS Worklets (animation, audio, paint, layout) | |
| * These are used for advanced CSS features and audio processing | |
| */ | |
| const worklets = [ | |
| ['animationWorklet', 'addModule'], | |
| ['audioWorklet', 'addModule'], | |
| ['paintWorklet', 'addModule'], | |
| ['layoutWorklet', 'addModule'], | |
| ]; | |
| for (const [key, method] of worklets) { | |
| const obj = window.CSS && window.CSS[key]; | |
| if (obj && typeof obj[method] === 'function') { | |
| const orig = obj[method].bind(obj); | |
| obj[method] = function (url, ...rest) { | |
| return orig(maybePatchUrl(url), ...rest); | |
| }; | |
| } | |
| } | |
| /** | |
| * Early patching of script element properties | |
| * Intercepts direct property access (e.g., script.src = '...') | |
| * Must run before any scripts are created | |
| */ | |
| (function patchScriptSrcEarly() { | |
| const desc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); | |
| if (desc && desc.set) { | |
| Object.defineProperty(HTMLScriptElement.prototype, 'src', { | |
| get() { return desc.get.call(this); }, | |
| set(v) { | |
| const patched = String(maybePatchUrl(v)); | |
| if (patched !== v) console.debug('[Production Patch] script.src:', v, '→', patched); | |
| return desc.set.call(this, patched); | |
| }, | |
| configurable: true | |
| }); | |
| } | |
| const origSetAttribute = Element.prototype.setAttribute; | |
| Element.prototype.setAttribute = function (name, value) { | |
| if (this.tagName === 'SCRIPT' && name === 'src') { | |
| value = String(maybePatchUrl(value)); | |
| } | |
| return origSetAttribute.call(this, name, value); | |
| }; | |
| })(); | |
| /** | |
| * Patch Trusted Types API - Critical for Flutter Web | |
| * Flutter uses Trusted Types for security when loading scripts dynamically | |
| * This intercepts policy creation to add cache-busting to validated URLs | |
| */ | |
| (function patchTrustedTypes() { | |
| if (!window.trustedTypes || !trustedTypes.createPolicy) return; | |
| const origCreatePolicy = trustedTypes.createPolicy.bind(trustedTypes); | |
| trustedTypes.createPolicy = function (name, rules) { | |
| if (rules && typeof rules.createScriptURL === 'function') { | |
| const orig = rules.createScriptURL; | |
| rules = { | |
| ...rules, | |
| createScriptURL(urlLike) { | |
| // Let the app's policy validate first | |
| const validated = orig.call(this, urlLike); | |
| // Then rewrite for cache-busting | |
| const patched = String(maybePatchUrl(String(validated))); | |
| if (patched !== String(validated)) { | |
| console.debug('[Production Patch] TT.createScriptURL:', String(validated), '→', patched); | |
| } | |
| // Returning a string is fine for the sinks Flutter uses (script.src, import()). | |
| return patched; | |
| } | |
| }; | |
| } | |
| return origCreatePolicy(name, rules); | |
| }; | |
| })(); | |
| /** | |
| * MutationObserver to catch dynamically added/modified elements | |
| * Monitors DOM for script and link elements added after page load | |
| * Essential for single-page applications like Flutter | |
| */ | |
| const observer = new MutationObserver((mutations) => { | |
| for (const m of mutations) { | |
| if (m.type === 'childList') { | |
| m.addedNodes.forEach((n) => { | |
| patchNode(n, /*justAdded*/ true); | |
| recursivelyPatch(n); | |
| }); | |
| } else if (m.type === 'attributes') { | |
| patchNode(m.target, /*justAdded*/ false); | |
| } | |
| } | |
| }); | |
| observer.observe(document.documentElement || document, { | |
| subtree: true, | |
| childList: true, | |
| attributes: true, | |
| attributeFilter: ['src', 'href', 'rel', 'as'] | |
| }); | |
| // Initial sweep of existing elements | |
| document.querySelectorAll('[src],[href]').forEach((n) => patchNode(n, true)); | |
| /** | |
| * Recursively patch child nodes when new subtrees are added | |
| * @param {Node} node - Parent node to traverse | |
| */ | |
| function recursivelyPatch(node) { | |
| if (node && node.childNodes) node.childNodes.forEach((c) => patchNode(c, true)); | |
| } | |
| /** | |
| * Process individual DOM nodes for patching | |
| * @param {Node} node - DOM node to process | |
| * @param {boolean} justAdded - True if node was just added to DOM | |
| */ | |
| function patchNode(node, justAdded) { | |
| if (!(node instanceof HTMLElement)) return; | |
| const tag = node.tagName.toLowerCase(); | |
| // Handle <script> elements | |
| if (tag === 'script' && node.src) { | |
| const before = node.src; | |
| const after = String(maybePatchUrl(before)); | |
| if (after !== before) { | |
| // Scripts already in DOM won't refetch if we just change src | |
| // Must recreate the element to trigger a new network request | |
| if (node.isConnected && !justAdded) { | |
| recreateScript(node, after); | |
| } else { | |
| node.src = after; | |
| } | |
| } | |
| return; | |
| } | |
| // Handle <link> elements (for module preloading) | |
| if (tag === 'link') { | |
| const rel = (node.getAttribute('rel') || '').toLowerCase(); | |
| const as = (node.getAttribute('as') || '').toLowerCase(); | |
| if (rel === 'modulepreload' || (rel === 'preload' && as === 'script')) { | |
| if (node.href) node.href = String(maybePatchUrl(node.href)); | |
| } | |
| } | |
| } | |
| /** | |
| * Recreates a script element to force browser to refetch | |
| * Simply changing src attribute doesn't trigger reload for connected scripts | |
| * @param {HTMLScriptElement} oldNode - Original script element | |
| * @param {string} newSrc - New source URL with cache-busting | |
| */ | |
| function recreateScript(oldNode, newSrc) { | |
| const newNode = document.createElement('script'); | |
| // Copy all attributes except src | |
| for (const attr of oldNode.attributes) { | |
| if (attr.name !== 'src') newNode.setAttribute(attr.name, attr.value); | |
| } | |
| // preserve inline content if any | |
| if (oldNode.text) newNode.text = oldNode.text; | |
| newNode.src = newSrc; | |
| oldNode.after(newNode); | |
| oldNode.remove(); | |
| } | |
| console.log('[Production Patch] Initialized - Content-based cache busting active'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Couple of fixes needed before it ran for me in github actions:
(fixes sed error on linux)
flutter-web-post-build.sh L39:
(fixes early exit code 1)
generate-asset-hashes.sh L65: