nandi/frqpublic Fork 0
ec5b47d
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Ten megabytes down to four, and a cold start that was a round trip

`http.server` compresses nothing, so dart2js output and the canvaskit
wasm went over the wire raw -- 3.6MB and 6.9MB of the critical path,
for files that gzip to a quarter of that. Compression happens once per
file per container and is held in memory, which also stops re-reading
them off a Volume mount that is a network filesystem, not a disk.

Cache-Control by name rather than by hash, because Flutter web does not
hash its filenames: the five files whose content changes every build
revalidate, everything else gets an hour. An hour and not `immutable`
is the honest bound when no URL here is safe to cache forever -- it is
also how long a rebuild takes to reach someone who has been before.

And the client metadata is compared before it is written. That
`Volume.commit` sat on the cold-start path of every request arriving
after a scaledown, for 540 bytes that change only when ORIGIN does.

Measured on the deployed URL: main.dart.js 3,780,683 -> 1,086,725
bytes, canvaskit.wasm 6.9MB -> 2,919,782, and a small file off a cold
container 5.76s -> 0.54s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T14:29:05-07:00 Browse files
ec5b47d parent: 480ff32
modified .modal/flutter-web/serve.py +147 -15
@@ -46,6 +46,33 @@ PORT = 8080
4646 # pasted images under /api/v1/media, and those are `:image` in the same chat.
4747 PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app")
4848
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.
53+#
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.
56+REVALIDATE = (
57+ "index.html",
58+ "flutter_bootstrap.js",
59+ "flutter_service_worker.js",
60+ "version.json",
61+ "client-metadata.json",
62+)
63+
64+MAX_AGE = 3600
65+
66+# Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip
67+# on dart2js output and on wasm is the single biggest win this server has.
68+COMPRESSIBLE = (
69+ "text/",
70+ "application/javascript",
71+ "application/json",
72+ "application/wasm",
73+ "image/svg+xml",
74+)
75+
4976 # This app's own origin, and the one thing here that is not derivable: an
5077 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
5178 # the document has to name the origin it will be fetched from. Modal's
@@ -110,22 +137,53 @@ def web():
110137 # not baked into the build, because it names the deployed origin, which is
111138 # a property of this Function and not of the ClojureDart.
112139 #
113- # The volume is shared and durable, so this also survives for the next
114- # cold start; rewriting it every time is what keeps ORIGIN and the file in
115- # step when one of them changes.
116- with open(f"{WEB_ROOT}/client-metadata.json", "w") as f:
117- json.dump(CLIENT_METADATA, f, indent=2)
118- DEVSHELL.commit()
140+ # The volume is shared and durable, so this survives for the next cold
141+ # start -- which is why writing it every time was waste. A `Volume.commit`
142+ # is a network round trip, and this one sat on the cold-start path of every
143+ # request that arrived after a scaledown, for a 540-byte file that changes
144+ # only when ORIGIN does. So: compare first, and write only on a difference.
145+ wanted = json.dumps(CLIENT_METADATA, indent=2)
146+ meta = f"{WEB_ROOT}/client-metadata.json"
147+ try:
148+ current = open(meta).read()
149+ except OSError:
150+ current = None
151+ if current != wanted:
152+ with open(meta, "w") as f:
153+ f.write(wanted)
154+ DEVSHELL.commit()
119155
120156 # `http.server` with one route bolted on, rather than `-m http.server`:
121157 # static files are still all the app wants on first load, but images need
122158 # somewhere same-origin to come from. Written out and run as a file
123159 # because `@modal.web_server` wants a process, not a handler object.
124- server = f"""
125-import http.server, os, socketserver, urllib.parse, urllib.request
160+ # Config injected as a prelude rather than interpolated through the source
161+ # below: an f-string would need every literal brace in the program doubled,
162+ # and the program has grown past the point where that is safe to edit.
163+ prelude = (
164+ f"ROOT = {WEB_ROOT!r}\n"
165+ f"HOSTS = {PROXY_HOSTS!r}\n"
166+ f"PORT = {PORT}\n"
167+ f"REVALIDATE = {REVALIDATE!r}\n"
168+ f"MAX_AGE = {MAX_AGE}\n"
169+ f"COMPRESSIBLE = {COMPRESSIBLE!r}\n"
170+ )
171+
172+ # `http.server` with three things bolted on, rather than `-m http.server`:
173+ # a same-origin route for images, gzip, and cache headers. Written out and
174+ # run as a file because `@modal.web_server` wants a process, not a handler
175+ # object.
176+ server = prelude + '''
177+import gzip, http.server, os, posixpath, socketserver, urllib.parse, urllib.request
178+
179+# path -> (mtime, content-type, gzipped bytes). Filled on first request for a
180+# file and reused for the life of the container, which does two things at once:
181+# it stops re-compressing the 3.6MB `main.dart.js` per request, and it stops
182+# re-reading it off the Volume mount, which is a network filesystem and not a
183+# local disk. A container serves one build, so mtime is all the invalidation
184+# this needs.
185+CACHE = {}
126186
127-ROOT = {WEB_ROOT!r}
128-HOSTS = {PROXY_HOSTS!r}
129187
130188 class H(http.server.SimpleHTTPRequestHandler):
131189 def __init__(self, *a, **kw):
@@ -140,9 +198,81 @@ class H(http.server.SimpleHTTPRequestHandler):
140198 self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
141199 self.end_headers()
142200
201+ def cache_control(self, path):
202+ if os.path.basename(path) in REVALIDATE:
203+ return "no-cache"
204+ return f"public, max-age={MAX_AGE}"
205+
206+ def entry(self, path):
207+ """(content-type, gzipped body) for a file, compressed at most once."""
208+ mtime = os.path.getmtime(path)
209+ hit = CACHE.get(path)
210+ if hit and hit[0] == mtime:
211+ return hit[1], hit[2]
212+ ctype = self.guess_type(path)
213+ with open(path, "rb") as f:
214+ body = f.read()
215+ # mtime=0 so the same input gives the same bytes; the gzip header's
216+ # timestamp is noise no client reads.
217+ body = gzip.compress(body, compresslevel=6, mtime=0)
218+ CACHE[path] = (mtime, ctype, body)
219+ return ctype, body
220+
143221 def do_GET(self):
144- if not self.path.startswith("/proxy?"):
222+ if self.path.startswith("/proxy?"):
223+ return self.proxy()
224+
225+ path = self.translate_path(self.path)
226+ if os.path.isdir(path):
227+ path = os.path.join(path, "index.html")
228+
229+ # Anything we cannot compress -- a missing file, an image, a client
230+ # that did not ask for gzip -- falls back to the stock handler, which
231+ # already does ranges, 304s and 404s correctly.
232+ ctype = self.guess_type(path) if os.path.isfile(path) else ""
233+ if (
234+ not os.path.isfile(path)
235+ or "gzip" not in self.headers.get("Accept-Encoding", "")
236+ or not ctype.startswith(COMPRESSIBLE)
237+ ):
238+ return self.stock_get(path)
239+
240+ try:
241+ ctype, body = self.entry(path)
242+ except OSError:
243+ return self.stock_get(path)
244+
245+ self.send_response(200)
246+ self.send_header("Content-Type", ctype)
247+ self.send_header("Content-Encoding", "gzip")
248+ self.send_header("Content-Length", str(len(body)))
249+ self.send_header("Vary", "Accept-Encoding")
250+ self.send_header("Cache-Control", self.cache_control(path))
251+ self.end_headers()
252+ self.wfile.write(body)
253+
254+ def stock_get(self, path):
255+ """SimpleHTTPRequestHandler's own GET, with our Cache-Control added."""
256+ # Only a real file gets a Cache-Control; a 404 must not be cached for
257+ # an hour just because it went through here.
258+ self._cc = self.cache_control(path) if os.path.isfile(path) else None
259+ try:
145260 return super().do_GET()
261+ finally:
262+ self._cc = None
263+
264+ def send_header(self, key, value):
265+ super().send_header(key, value)
266+ # The stock handler ends its headers itself, so there is no seam to add
267+ # one -- except here, riding along with the header it always sends.
268+ # `Content-type`, lowercase t: that is the spelling the stock handler
269+ # uses, and matching it case-sensitively is how this silently did
270+ # nothing the first time.
271+ if key.lower() == "content-type" and getattr(self, "_cc", None):
272+ cc, self._cc = self._cc, None
273+ super().send_header("Cache-Control", cc)
274+
275+ def proxy(self):
146276 q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
147277 target = (q.get("url") or [""])[0]
148278 parts = urllib.parse.urlparse(target)
@@ -152,12 +282,12 @@ class H(http.server.SimpleHTTPRequestHandler):
152282 self.send_error(403, "host not proxied")
153283 return
154284 try:
155- req = urllib.request.Request(target, headers={{"user-agent": "frq"}})
285+ req = urllib.request.Request(target, headers={"user-agent": "frq"})
156286 with urllib.request.urlopen(req, timeout=30) as up:
157287 body = up.read()
158288 ctype = up.headers.get("content-type", "application/octet-stream")
159289 except Exception as e:
160- self.send_error(502, f"upstream: {{e}}")
290+ self.send_error(502, f"upstream: {e}")
161291 return
162292 self.send_response(200)
163293 self.send_header("Content-Type", ctype)
@@ -168,14 +298,16 @@ class H(http.server.SimpleHTTPRequestHandler):
168298 self.end_headers()
169299 self.wfile.write(body)
170300
301+
171302 class S(socketserver.ThreadingTCPServer):
172303 # Threaded, because a proxied fetch blocks: one slow avatar must not stop
173304 # the page loading. daemon_threads so the process can still exit.
174305 allow_reuse_address = True
175306 daemon_threads = True
176307
177-S(("", {PORT}), H).serve_forever()
178-"""
308+
309+S(("", PORT), H).serve_forever()
310+'''
179311 with open("/tmp/frq_serve.py", "w") as f:
180312 f.write(server)
181313
@@ -46,6 +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
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.
53+#
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.
56+REVALIDATE = (
57+ "index.html",
58+ "flutter_bootstrap.js",
59+ "flutter_service_worker.js",
60+ "version.json",
61+ "client-metadata.json",
62+)
63+
64+MAX_AGE = 3600
65+
66+# Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip
67+# on dart2js output and on wasm is the single biggest win this server has.
68+COMPRESSIBLE = (
69+ "text/",
70+ "application/javascript",
71+ "application/json",
72+ "application/wasm",
73+ "image/svg+xml",
74+)
75+
49 # This app's own origin, and the one thing here that is not derivable: an76 # This app's own origin, and the one thing here that is not derivable: an
50 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so77 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
51 # the document has to name the origin it will be fetched from. Modal's78 # the document has to name the origin it will be fetched from. Modal's
@@ -110,22 +137,53 @@ def web():
110 # not baked into the build, because it names the deployed origin, which is137 # not baked into the build, because it names the deployed origin, which is
111 # a property of this Function and not of the ClojureDart.138 # a property of this Function and not of the ClojureDart.
112 #139 #
113- # The volume is shared and durable, so this also survives for the next140+ # The volume is shared and durable, so this survives for the next cold
114- # cold start; rewriting it every time is what keeps ORIGIN and the file in141+ # start -- which is why writing it every time was waste. A `Volume.commit`
115- # step when one of them changes.142+ # is a network round trip, and this one sat on the cold-start path of every
116- with open(f"{WEB_ROOT}/client-metadata.json", "w") as f:143+ # request that arrived after a scaledown, for a 540-byte file that changes
117- json.dump(CLIENT_METADATA, f, indent=2)144+ # only when ORIGIN does. So: compare first, and write only on a difference.
118- DEVSHELL.commit()145+ wanted = json.dumps(CLIENT_METADATA, indent=2)
146+ meta = f"{WEB_ROOT}/client-metadata.json"
147+ try:
148+ current = open(meta).read()
149+ except OSError:
150+ current = None
151+ if current != wanted:
152+ with open(meta, "w") as f:
153+ f.write(wanted)
154+ DEVSHELL.commit()
119 155
120 # `http.server` with one route bolted on, rather than `-m http.server`:156 # `http.server` with one route bolted on, rather than `-m http.server`:
121 # static files are still all the app wants on first load, but images need157 # static files are still all the app wants on first load, but images need
122 # somewhere same-origin to come from. Written out and run as a file158 # somewhere same-origin to come from. Written out and run as a file
123 # because `@modal.web_server` wants a process, not a handler object.159 # because `@modal.web_server` wants a process, not a handler object.
124- server = f"""160+ # Config injected as a prelude rather than interpolated through the source
125-import http.server, os, socketserver, urllib.parse, urllib.request161+ # below: an f-string would need every literal brace in the program doubled,
162+ # and the program has grown past the point where that is safe to edit.
163+ prelude = (
164+ f"ROOT = {WEB_ROOT!r}\n"
165+ f"HOSTS = {PROXY_HOSTS!r}\n"
166+ f"PORT = {PORT}\n"
167+ f"REVALIDATE = {REVALIDATE!r}\n"
168+ f"MAX_AGE = {MAX_AGE}\n"
169+ f"COMPRESSIBLE = {COMPRESSIBLE!r}\n"
170+ )
171+
172+ # `http.server` with three things bolted on, rather than `-m http.server`:
173+ # a same-origin route for images, gzip, and cache headers. Written out and
174+ # run as a file because `@modal.web_server` wants a process, not a handler
175+ # object.
176+ server = prelude + '''
177+import gzip, http.server, os, posixpath, socketserver, urllib.parse, urllib.request
178+
179+# path -> (mtime, content-type, gzipped bytes). Filled on first request for a
180+# file and reused for the life of the container, which does two things at once:
181+# it stops re-compressing the 3.6MB `main.dart.js` per request, and it stops
182+# re-reading it off the Volume mount, which is a network filesystem and not a
183+# local disk. A container serves one build, so mtime is all the invalidation
184+# this needs.
185+CACHE = {}
126 186
127-ROOT = {WEB_ROOT!r}
128-HOSTS = {PROXY_HOSTS!r}
129 187
130 class H(http.server.SimpleHTTPRequestHandler):188 class H(http.server.SimpleHTTPRequestHandler):
131 def __init__(self, *a, **kw):189 def __init__(self, *a, **kw):
@@ -140,9 +198,81 @@ class H(http.server.SimpleHTTPRequestHandler):
140 self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")198 self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
141 self.end_headers()199 self.end_headers()
142 200
201+ def cache_control(self, path):
202+ if os.path.basename(path) in REVALIDATE:
203+ return "no-cache"
204+ return f"public, max-age={MAX_AGE}"
205+
206+ def entry(self, path):
207+ """(content-type, gzipped body) for a file, compressed at most once."""
208+ mtime = os.path.getmtime(path)
209+ hit = CACHE.get(path)
210+ if hit and hit[0] == mtime:
211+ return hit[1], hit[2]
212+ ctype = self.guess_type(path)
213+ with open(path, "rb") as f:
214+ body = f.read()
215+ # mtime=0 so the same input gives the same bytes; the gzip header's
216+ # timestamp is noise no client reads.
217+ body = gzip.compress(body, compresslevel=6, mtime=0)
218+ CACHE[path] = (mtime, ctype, body)
219+ return ctype, body
220+
143 def do_GET(self):221 def do_GET(self):
144- if not self.path.startswith("/proxy?"):222+ if self.path.startswith("/proxy?"):
223+ return self.proxy()
224+
225+ path = self.translate_path(self.path)
226+ if os.path.isdir(path):
227+ path = os.path.join(path, "index.html")
228+
229+ # Anything we cannot compress -- a missing file, an image, a client
230+ # that did not ask for gzip -- falls back to the stock handler, which
231+ # already does ranges, 304s and 404s correctly.
232+ ctype = self.guess_type(path) if os.path.isfile(path) else ""
233+ if (
234+ not os.path.isfile(path)
235+ or "gzip" not in self.headers.get("Accept-Encoding", "")
236+ or not ctype.startswith(COMPRESSIBLE)
237+ ):
238+ return self.stock_get(path)
239+
240+ try:
241+ ctype, body = self.entry(path)
242+ except OSError:
243+ return self.stock_get(path)
244+
245+ self.send_response(200)
246+ self.send_header("Content-Type", ctype)
247+ self.send_header("Content-Encoding", "gzip")
248+ self.send_header("Content-Length", str(len(body)))
249+ self.send_header("Vary", "Accept-Encoding")
250+ self.send_header("Cache-Control", self.cache_control(path))
251+ self.end_headers()
252+ self.wfile.write(body)
253+
254+ def stock_get(self, path):
255+ """SimpleHTTPRequestHandler's own GET, with our Cache-Control added."""
256+ # Only a real file gets a Cache-Control; a 404 must not be cached for
257+ # an hour just because it went through here.
258+ self._cc = self.cache_control(path) if os.path.isfile(path) else None
259+ try:
145 return super().do_GET()260 return super().do_GET()
261+ finally:
262+ self._cc = None
263+
264+ def send_header(self, key, value):
265+ super().send_header(key, value)
266+ # The stock handler ends its headers itself, so there is no seam to add
267+ # one -- except here, riding along with the header it always sends.
268+ # `Content-type`, lowercase t: that is the spelling the stock handler
269+ # uses, and matching it case-sensitively is how this silently did
270+ # nothing the first time.
271+ if key.lower() == "content-type" and getattr(self, "_cc", None):
272+ cc, self._cc = self._cc, None
273+ super().send_header("Cache-Control", cc)
274+
275+ def proxy(self):
146 q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)276 q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
147 target = (q.get("url") or [""])[0]277 target = (q.get("url") or [""])[0]
148 parts = urllib.parse.urlparse(target)278 parts = urllib.parse.urlparse(target)
@@ -152,12 +282,12 @@ class H(http.server.SimpleHTTPRequestHandler):
152 self.send_error(403, "host not proxied")282 self.send_error(403, "host not proxied")
153 return283 return
154 try:284 try:
155- req = urllib.request.Request(target, headers={{"user-agent": "frq"}})285+ req = urllib.request.Request(target, headers={"user-agent": "frq"})
156 with urllib.request.urlopen(req, timeout=30) as up:286 with urllib.request.urlopen(req, timeout=30) as up:
157 body = up.read()287 body = up.read()
158 ctype = up.headers.get("content-type", "application/octet-stream")288 ctype = up.headers.get("content-type", "application/octet-stream")
159 except Exception as e:289 except Exception as e:
160- self.send_error(502, f"upstream: {{e}}")290+ self.send_error(502, f"upstream: {e}")
161 return291 return
162 self.send_response(200)292 self.send_response(200)
163 self.send_header("Content-Type", ctype)293 self.send_header("Content-Type", ctype)
@@ -168,14 +298,16 @@ class H(http.server.SimpleHTTPRequestHandler):
168 self.end_headers()298 self.end_headers()
169 self.wfile.write(body)299 self.wfile.write(body)
170 300
301+
171 class S(socketserver.ThreadingTCPServer):302 class S(socketserver.ThreadingTCPServer):
172 # Threaded, because a proxied fetch blocks: one slow avatar must not stop303 # Threaded, because a proxied fetch blocks: one slow avatar must not stop
173 # the page loading. daemon_threads so the process can still exit.304 # the page loading. daemon_threads so the process can still exit.
174 allow_reuse_address = True305 allow_reuse_address = True
175 daemon_threads = True306 daemon_threads = True
176 307
177-S(("", {PORT}), H).serve_forever()308+
178-"""309+S(("", PORT), H).serve_forever()
310+'''
179 with open("/tmp/frq_serve.py", "w") as f:311 with open("/tmp/frq_serve.py", "w") as f:
180 f.write(server)312 f.write(server)
181 313