Skip to content

Instantly share code, notes, and snippets.

@animeshjain
Created August 4, 2025 17:16
Show Gist options
  • Select an option

  • Save animeshjain/6da1152e3636c2b69cbc27d79ba54b54 to your computer and use it in GitHub Desktop.

Select an option

Save animeshjain/6da1152e3636c2b69cbc27d79ba54b54 to your computer and use it in GitHub Desktop.
Content-Hashed Caching for Flutter Web (Without a Service Worker)
#!/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."
#!/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"
/**
* 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');
})();
@johsoe
Copy link

johsoe commented Aug 22, 2025

Couple of fixes needed before it ran for me in github actions:

(fixes sed error on linux)
flutter-web-post-build.sh L39:

 # 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
        if [[ "$OSTYPE" == "darwin"* ]]; then
            # macOS sed requires different syntax
            sed -i '' "s|production-patch-loader\.js\"|production-patch-loader.js?t=$TIMESTAMP\"|g" "$INDEX_HTML"
        else
            # Linux sed
            sed -i "s|production-patch-loader\.js\"|production-patch-loader.js?t=$TIMESTAMP\"|g" "$INDEX_HTML"
        fi

(fixes early exit code 1)
generate-asset-hashes.sh L65:

    FILE_COUNT=$((FILE_COUNT+1))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment