An hour of cache is a day of debugging
`main.dart.js` was given `max-age=3600` on the theory that a repeat visit should cost no round trips. Flutter web does not hash its filenames, so that pinned one build in every browser that had loaded the app: `index.html` revalidated and truthfully said it had not changed, while the JS it pulls in was an hour old. Three deploys were then tested against code that was never running, and two of those tests were reported as failures of the fix rather than of the header. So the per-build files revalidate now -- `no-cache`, a 304 with no body when they match -- and only `canvaskit/` keeps a max-age, since it tracks the Flutter SDK and not this app. Anything unrecognised revalidates too: being wrong that way costs a round trip, and being wrong the other way costs an hour of serving code that no longer exists. gzip is untouched, which is where the bytes were actually saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562900f parent: 8d6f6cb modified
.modal/flutter-web/serve.py +26 -7 | @@ -46,21 +46,33 @@ PORT = 8080 | ||
| 46 | 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. |
| 47 | 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") |
| 48 | 48 | |
| 49 | -# Files whose name is stable but whose *content* changes with every build, so a | |
| 50 | -# browser must ask before reusing one. Everything else -- `main.dart.js`, the | |
| 51 | -# canvaskit wasm, the assets -- gets a bounded max-age below: an hour of | |
| 52 | -# staleness in exchange for a repeat visit that costs no round trips. | |
| 49 | +# Files a browser must ask about before reusing. `main.dart.js` is on this list | |
| 50 | +# the hard way: it was given `max-age=3600` on the theory that an hour of | |
| 51 | +# staleness was a fair price for a repeat visit with no round trips, and the | |
| 52 | +# next three deploys were then tested against a bundle the browser had pinned | |
| 53 | +# in cache. `index.html` revalidated and said nothing had changed, because | |
| 54 | +# nothing in *it* had; the JS it pulls in was an hour old. Two debugging | |
| 55 | +# sessions went into code that was never running. | |
| 53 | 56 | # |
| 54 | -# Why an hour and not `immutable`: Flutter web does not hash its filenames, so | |
| 55 | -# there is no URL that is safe to cache forever. The bound is the honest answer. | |
| 57 | +# Flutter web does not hash its filenames, so there is no URL here whose | |
| 58 | +# content is fixed, and `no-cache` -- revalidate every time, 304 with no body | |
| 59 | +# when it matches -- is the only honest header for a file that changes on | |
| 60 | +# every build. Only `canvaskit/` keeps a max-age: it changes with the Flutter | |
| 61 | +# SDK and not with this app, and it is the largest thing on the page. | |
| 56 | 62 | REVALIDATE = ( |
| 57 | 63 | "index.html", |
| 64 | + "main.dart.js", | |
| 65 | + "flutter.js", | |
| 58 | 66 | "flutter_bootstrap.js", |
| 59 | 67 | "flutter_service_worker.js", |
| 68 | + "manifest.json", | |
| 60 | 69 | "version.json", |
| 61 | 70 | "client-metadata.json", |
| 62 | 71 | ) |
| 63 | 72 | |
| 73 | +# Prefixes that keep the bounded max-age, by published path. | |
| 74 | +LONG_CACHE = ("canvaskit/",) | |
| 75 | + | |
| 64 | 76 | MAX_AGE = 3600 |
| 65 | 77 | |
| 66 | 78 | # Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip |
| @@ -165,6 +177,7 @@ def web(): | ||
| 165 | 177 | f"HOSTS = {PROXY_HOSTS!r}\n" |
| 166 | 178 | f"PORT = {PORT}\n" |
| 167 | 179 | f"REVALIDATE = {REVALIDATE!r}\n" |
| 180 | + f"LONG_CACHE = {LONG_CACHE!r}\n" | |
| 168 | 181 | f"MAX_AGE = {MAX_AGE}\n" |
| 169 | 182 | f"COMPRESSIBLE = {COMPRESSIBLE!r}\n" |
| 170 | 183 | ) |
| @@ -201,7 +214,13 @@ class H(http.server.SimpleHTTPRequestHandler): | ||
| 201 | 214 | def cache_control(self, path): |
| 202 | 215 | if os.path.basename(path) in REVALIDATE: |
| 203 | 216 | return "no-cache" |
| 204 | - return f"public, max-age={MAX_AGE}" | |
| 217 | + rel = os.path.relpath(path, ROOT).replace(os.sep, "/") | |
| 218 | + if rel.startswith(LONG_CACHE): | |
| 219 | + return f"public, max-age={MAX_AGE}" | |
| 220 | + # Everything unrecognised revalidates too. Being wrong in this | |
| 221 | + # direction costs a round trip; being wrong the other way costs an | |
| 222 | + # hour of serving code that no longer exists. | |
| 223 | + return "no-cache" | |
| 205 | 224 | |
| 206 | 225 | def entry(self, path): |
| 207 | 226 | """(content-type, gzipped body) for a file, compressed at most once.""" |
| @@ -46,21 +46,33 @@ PORT = 8080 | |||
| 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. | 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. |
| 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") | 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") |
| 48 | 48 | ||
| 49 | -# Files whose name is stable but whose *content* changes with every build, so a | 49 | +# Files a browser must ask about before reusing. `main.dart.js` is on this list |
| 50 | -# browser must ask before reusing one. Everything else -- `main.dart.js`, the | 50 | +# the hard way: it was given `max-age=3600` on the theory that an hour of |
| 51 | -# canvaskit wasm, the assets -- gets a bounded max-age below: an hour of | 51 | +# staleness was a fair price for a repeat visit with no round trips, and the |
| 52 | -# staleness in exchange for a repeat visit that costs no round trips. | 52 | +# next three deploys were then tested against a bundle the browser had pinned |
| 53 | +# in cache. `index.html` revalidated and said nothing had changed, because | ||
| 54 | +# nothing in *it* had; the JS it pulls in was an hour old. Two debugging | ||
| 55 | +# sessions went into code that was never running. | ||
| 53 | # | 56 | # |
| 54 | -# Why an hour and not `immutable`: Flutter web does not hash its filenames, so | 57 | +# Flutter web does not hash its filenames, so there is no URL here whose |
| 55 | -# there is no URL that is safe to cache forever. The bound is the honest answer. | 58 | +# content is fixed, and `no-cache` -- revalidate every time, 304 with no body |
| 59 | +# when it matches -- is the only honest header for a file that changes on | ||
| 60 | +# every build. Only `canvaskit/` keeps a max-age: it changes with the Flutter | ||
| 61 | +# SDK and not with this app, and it is the largest thing on the page. | ||
| 56 | REVALIDATE = ( | 62 | REVALIDATE = ( |
| 57 | "index.html", | 63 | "index.html", |
| 64 | + "main.dart.js", | ||
| 65 | + "flutter.js", | ||
| 58 | "flutter_bootstrap.js", | 66 | "flutter_bootstrap.js", |
| 59 | "flutter_service_worker.js", | 67 | "flutter_service_worker.js", |
| 68 | + "manifest.json", | ||
| 60 | "version.json", | 69 | "version.json", |
| 61 | "client-metadata.json", | 70 | "client-metadata.json", |
| 62 | ) | 71 | ) |
| 63 | 72 | ||
| 73 | +# Prefixes that keep the bounded max-age, by published path. | ||
| 74 | +LONG_CACHE = ("canvaskit/",) | ||
| 75 | + | ||
| 64 | MAX_AGE = 3600 | 76 | MAX_AGE = 3600 |
| 65 | 77 | ||
| 66 | # Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip | 78 | # Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip |
| @@ -165,6 +177,7 @@ def web(): | |||
| 165 | f"HOSTS = {PROXY_HOSTS!r}\n" | 177 | f"HOSTS = {PROXY_HOSTS!r}\n" |
| 166 | f"PORT = {PORT}\n" | 178 | f"PORT = {PORT}\n" |
| 167 | f"REVALIDATE = {REVALIDATE!r}\n" | 179 | f"REVALIDATE = {REVALIDATE!r}\n" |
| 180 | + f"LONG_CACHE = {LONG_CACHE!r}\n" | ||
| 168 | f"MAX_AGE = {MAX_AGE}\n" | 181 | f"MAX_AGE = {MAX_AGE}\n" |
| 169 | f"COMPRESSIBLE = {COMPRESSIBLE!r}\n" | 182 | f"COMPRESSIBLE = {COMPRESSIBLE!r}\n" |
| 170 | ) | 183 | ) |
| @@ -201,7 +214,13 @@ class H(http.server.SimpleHTTPRequestHandler): | |||
| 201 | def cache_control(self, path): | 214 | def cache_control(self, path): |
| 202 | if os.path.basename(path) in REVALIDATE: | 215 | if os.path.basename(path) in REVALIDATE: |
| 203 | return "no-cache" | 216 | return "no-cache" |
| 204 | - return f"public, max-age={MAX_AGE}" | 217 | + rel = os.path.relpath(path, ROOT).replace(os.sep, "/") |
| 218 | + if rel.startswith(LONG_CACHE): | ||
| 219 | + return f"public, max-age={MAX_AGE}" | ||
| 220 | + # Everything unrecognised revalidates too. Being wrong in this | ||
| 221 | + # direction costs a round trip; being wrong the other way costs an | ||
| 222 | + # hour of serving code that no longer exists. | ||
| 223 | + return "no-cache" | ||
| 205 | 224 | ||
| 206 | def entry(self, path): | 225 | def entry(self, path): |
| 207 | """(content-type, gzipped body) for a file, compressed at most once.""" | 226 | """(content-type, gzipped body) for a file, compressed at most once.""" |