nandi/frqpublic Fork 0
56551a8157331acb8601841b63f2dc7424339722
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.

serve.py · 336 lines · 14.4 KBPython Blame HistoryRaw
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago1"""The built web app, served from the volume the container built it into.
2
3Not part of `container.py`, and not a key in `container.toml`, because it is
4the other half of a split the build already makes: the Sandbox compiles into
5`/devshell/frq-flutter-web` and exits, and what it leaves behind is a
6directory of static files that outlives it. A Function mounting the same
7volume can hand those out without rebuilding anything, and can scale to zero
8between readers -- which a Sandbox holding a tunnel open cannot.
9
10 modal serve .modal/flutter-web/serve.py # while editing, auto-reloads
11 modal deploy .modal/flutter-web/serve.py # a URL that stays
12
13Deliberately NOT built on the container's own image. That image carries the
14repo and a few gigabytes of devShell closure, all of it for a compile that
15has already happened somewhere else; the bytes this serves come off the
16volume, so the image needs a python and nothing more. Cold starts are the
17difference.
18"""
19
20import json
21import subprocess
22
23import modal
24
25# The same volume the container writes to, named the same way. `from_name` is
26# lazy, so naming it here costs nothing until a container actually mounts it.
27DEVSHELL = modal.Volume.from_name("devshell", create_if_missing=True)
28
29# Where `just flutter-web` leaves its output, under this devShell's own
30# directory on the shared volume -- the same path container.toml builds in.
31WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web"
32
33PORT = 8080
34
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago35# Hosts the image proxy will fetch from. An allowlist and not a wildcard: a
36# proxy that fetches anything is an open proxy, and this one answers on a
37# public URL.
38#
39# Why it exists at all: `cdn.bsky.app` serves avatars with NO
40# `Access-Control-Allow-Origin` header, and Flutter web loads images through
41# XHR — so the browser fetches the bytes, sees no CORS header, and throws them
42# away. Nothing in the client can change that; the header is the far side's to
43# send. The desktop and the APK are unaffected, because neither is a browser.
44#
45# `irc.freeq.at` is here for the same reason and a second one: freeq serves
46# pasted images under /api/v1/media, and those are `:image` in the same chat.
47PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app")
48
An hour of cache is a day of debugging 562900f nandi yesterday49# 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.
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday56#
An hour of cache is a day of debugging 562900f nandi yesterday57# 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.
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday62REVALIDATE = (
63 "index.html",
An hour of cache is a day of debugging 562900f nandi yesterday64 "main.dart.js",
65 "flutter.js",
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday66 "flutter_bootstrap.js",
67 "flutter_service_worker.js",
An hour of cache is a day of debugging 562900f nandi yesterday68 "manifest.json",
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday69 "version.json",
70 "client-metadata.json",
71)
72
An hour of cache is a day of debugging 562900f nandi yesterday73# Prefixes that keep the bounded max-age, by published path.
74LONG_CACHE = ("canvaskit/",)
75
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday76MAX_AGE = 3600
77
78# Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip
79# on dart2js output and on wasm is the single biggest win this server has.
80COMPRESSIBLE = (
81 "text/",
82 "application/javascript",
83 "application/json",
84 "application/wasm",
85 "image/svg+xml",
86)
87
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago88# This app's own origin, and the one thing here that is not derivable: an
89# OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
90# the document has to name the origin it will be fetched from. Modal's
91# deployed URL is stable for a given app name, which is what makes that safe
92# to write down.
93ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run"
94
95# The OAuth client identity, served at `/client-metadata.json`.
96#
97# This is what makes Bluesky sign-in possible from this origin at all. freeq's
98# auth broker will only redirect to hosts on its own allowlist, and this one is
99# not among them -- but a client that runs the OAuth flow *itself* is not asking
100# the broker for anything. An AT Protocol authorization server fetches this
101# document from the client_id URL and takes it as the authority on where a code
102# may be sent, so the allowlist that matters is the `redirect_uris` below, which
103# we publish.
104#
105# A public client: `token_endpoint_auth_method: none` and no secret, because a
106# page in a browser can keep none. What stands in for one is DPoP -- every token
107# is bound to a key the client proves it holds, which is also exactly what
108# freeq's SASL `pds-oauth` method verifies.
109CLIENT_METADATA = {
110 "client_id": f"{ORIGIN}/client-metadata.json",
111 "client_name": "frq",
112 "client_uri": f"{ORIGIN}/",
113 "redirect_uris": [f"{ORIGIN}/"],
114 "grant_types": ["authorization_code", "refresh_token"],
115 "response_types": ["code"],
116 # `atproto` is the identity scope freeq needs; `transition:generic` is what
117 # a PDS still wants for ordinary reads and writes.
118 "scope": "atproto transition:generic",
119 "token_endpoint_auth_method": "none",
120 "application_type": "web",
121 "dpop_bound_access_tokens": True,
122}
123
124app = modal.App("frq-flutter-web-serve")
125
126image = modal.Image.debian_slim(python_version="3.12")
127
128
129@app.function(
130 image=image,
131 volumes={"/devshell": DEVSHELL},
132 # Nothing here holds state between requests, and a reader who wanders off
133 # should stop costing anything -- so let it go to zero quickly rather than
134 # keeping a container warm for a static directory.
135 scaledown_window=60,
136)
137@modal.web_server(PORT, startup_timeout=30)
138def web():
139 # A Volume mount is a snapshot taken when the container starts, so a
140 # container that outlives a build would keep serving the old one. Reload
141 # first and the newest committed build is what gets served -- which is the
142 # whole point of the split: rebuild in the Sandbox, and the next cold
143 # start here picks it up with nothing redeployed.
144 DEVSHELL.reload()
145
146 # The client metadata is written beside the bundle rather than served by a
147 # route of its own: `http.server` has no routing table, and one file on
148 # disk is less machinery than a handler subclass. Written at start-up and
149 # not baked into the build, because it names the deployed origin, which is
150 # a property of this Function and not of the ClojureDart.
151 #
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday152 # The volume is shared and durable, so this survives for the next cold
153 # start -- which is why writing it every time was waste. A `Volume.commit`
154 # is a network round trip, and this one sat on the cold-start path of every
155 # request that arrived after a scaledown, for a 540-byte file that changes
156 # only when ORIGIN does. So: compare first, and write only on a difference.
157 wanted = json.dumps(CLIENT_METADATA, indent=2)
158 meta = f"{WEB_ROOT}/client-metadata.json"
159 try:
160 current = open(meta).read()
161 except OSError:
162 current = None
163 if current != wanted:
164 with open(meta, "w") as f:
165 f.write(wanted)
166 DEVSHELL.commit()
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago167
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago168 # `http.server` with one route bolted on, rather than `-m http.server`:
169 # static files are still all the app wants on first load, but images need
170 # somewhere same-origin to come from. Written out and run as a file
171 # because `@modal.web_server` wants a process, not a handler object.
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday172 # Config injected as a prelude rather than interpolated through the source
173 # below: an f-string would need every literal brace in the program doubled,
174 # and the program has grown past the point where that is safe to edit.
175 prelude = (
176 f"ROOT = {WEB_ROOT!r}\n"
177 f"HOSTS = {PROXY_HOSTS!r}\n"
178 f"PORT = {PORT}\n"
179 f"REVALIDATE = {REVALIDATE!r}\n"
An hour of cache is a day of debugging 562900f nandi yesterday180 f"LONG_CACHE = {LONG_CACHE!r}\n"
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday181 f"MAX_AGE = {MAX_AGE}\n"
182 f"COMPRESSIBLE = {COMPRESSIBLE!r}\n"
183 )
184
185 # `http.server` with three things bolted on, rather than `-m http.server`:
186 # a same-origin route for images, gzip, and cache headers. Written out and
187 # run as a file because `@modal.web_server` wants a process, not a handler
188 # object.
189 server = prelude + '''
190import gzip, http.server, os, posixpath, socketserver, urllib.parse, urllib.request
191
192# path -> (mtime, content-type, gzipped bytes). Filled on first request for a
193# file and reused for the life of the container, which does two things at once:
194# it stops re-compressing the 3.6MB `main.dart.js` per request, and it stops
195# re-reading it off the Volume mount, which is a network filesystem and not a
196# local disk. A container serves one build, so mtime is all the invalidation
197# this needs.
198CACHE = {}
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago199
200
201class H(http.server.SimpleHTTPRequestHandler):
202 def __init__(self, *a, **kw):
203 super().__init__(*a, directory=ROOT, **kw)
204
205 def do_OPTIONS(self):
206 # The preflight. A cross-origin image fetch does not send one, but the
207 # XHRs that read freeq's API do, and answering it is two lines.
208 self.send_response(204)
209 self.send_header("Access-Control-Allow-Origin", "*")
210 self.send_header("Access-Control-Allow-Headers", "*")
211 self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
212 self.end_headers()
213
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday214 def cache_control(self, path):
215 if os.path.basename(path) in REVALIDATE:
216 return "no-cache"
An hour of cache is a day of debugging 562900f nandi yesterday217 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"
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday224
225 def entry(self, path):
226 """(content-type, gzipped body) for a file, compressed at most once."""
227 mtime = os.path.getmtime(path)
228 hit = CACHE.get(path)
229 if hit and hit[0] == mtime:
230 return hit[1], hit[2]
231 ctype = self.guess_type(path)
232 with open(path, "rb") as f:
233 body = f.read()
234 # mtime=0 so the same input gives the same bytes; the gzip header's
235 # timestamp is noise no client reads.
236 body = gzip.compress(body, compresslevel=6, mtime=0)
237 CACHE[path] = (mtime, ctype, body)
238 return ctype, body
239
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago240 def do_GET(self):
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday241 if self.path.startswith("/proxy?"):
242 return self.proxy()
243
244 path = self.translate_path(self.path)
245 if os.path.isdir(path):
246 path = os.path.join(path, "index.html")
247
248 # Anything we cannot compress -- a missing file, an image, a client
249 # that did not ask for gzip -- falls back to the stock handler, which
250 # already does ranges, 304s and 404s correctly.
251 ctype = self.guess_type(path) if os.path.isfile(path) else ""
252 if (
253 not os.path.isfile(path)
254 or "gzip" not in self.headers.get("Accept-Encoding", "")
255 or not ctype.startswith(COMPRESSIBLE)
256 ):
257 return self.stock_get(path)
258
259 try:
260 ctype, body = self.entry(path)
261 except OSError:
262 return self.stock_get(path)
263
264 self.send_response(200)
265 self.send_header("Content-Type", ctype)
266 self.send_header("Content-Encoding", "gzip")
267 self.send_header("Content-Length", str(len(body)))
268 self.send_header("Vary", "Accept-Encoding")
269 self.send_header("Cache-Control", self.cache_control(path))
270 self.end_headers()
271 self.wfile.write(body)
272
273 def stock_get(self, path):
274 """SimpleHTTPRequestHandler's own GET, with our Cache-Control added."""
275 # Only a real file gets a Cache-Control; a 404 must not be cached for
276 # an hour just because it went through here.
277 self._cc = self.cache_control(path) if os.path.isfile(path) else None
278 try:
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago279 return super().do_GET()
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday280 finally:
281 self._cc = None
282
283 def send_header(self, key, value):
284 super().send_header(key, value)
285 # The stock handler ends its headers itself, so there is no seam to add
286 # one -- except here, riding along with the header it always sends.
287 # `Content-type`, lowercase t: that is the spelling the stock handler
288 # uses, and matching it case-sensitively is how this silently did
289 # nothing the first time.
290 if key.lower() == "content-type" and getattr(self, "_cc", None):
291 cc, self._cc = self._cc, None
292 super().send_header("Cache-Control", cc)
293
294 def proxy(self):
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago295 q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
296 target = (q.get("url") or [""])[0]
297 parts = urllib.parse.urlparse(target)
298 # Allowlisted https hosts only. Anything else and this is an open
299 # relay wearing our origin.
300 if parts.scheme != "https" or parts.hostname not in HOSTS:
301 self.send_error(403, "host not proxied")
302 return
303 try:
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday304 req = urllib.request.Request(target, headers={"user-agent": "frq"})
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago305 with urllib.request.urlopen(req, timeout=30) as up:
306 body = up.read()
307 ctype = up.headers.get("content-type", "application/octet-stream")
308 except Exception as e:
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday309 self.send_error(502, f"upstream: {e}")
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago310 return
311 self.send_response(200)
312 self.send_header("Content-Type", ctype)
313 self.send_header("Content-Length", str(len(body)))
314 # The whole point: our origin says yes where the far side said nothing.
315 self.send_header("Access-Control-Allow-Origin", "*")
316 self.send_header("Cache-Control", "public, max-age=3600")
317 self.end_headers()
318 self.wfile.write(body)
319
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday320
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago321class S(socketserver.ThreadingTCPServer):
322 # Threaded, because a proxied fetch blocks: one slow avatar must not stop
323 # the page loading. daemon_threads so the process can still exit.
324 allow_reuse_address = True
325 daemon_threads = True
326
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi yesterday327
328S(("", PORT), H).serve_forever()
329'''
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago330 with open("/tmp/frq_serve.py", "w") as f:
331 f.write(server)
332
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago333 # Popen and not run: `@modal.web_server` expects the body to *start* a
334 # server and return, so Modal can begin proxying. Blocking here would time
335 # out at startup_timeout with nothing ever listening.
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago336 subprocess.Popen(["python", "/tmp/frq_serve.py"])