nandi/frqpublic Fork 0
8d6f6cb7f753d19995f4201f56e6b8d677e90fd1
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 · 317 lines · 13.5 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
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago49# 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.
56REVALIDATE = (
57 "index.html",
58 "flutter_bootstrap.js",
59 "flutter_service_worker.js",
60 "version.json",
61 "client-metadata.json",
62)
63
64MAX_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.
68COMPRESSIBLE = (
69 "text/",
70 "application/javascript",
71 "application/json",
72 "application/wasm",
73 "image/svg+xml",
74)
75
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago76# This app's own origin, and the one thing here that is not derivable: an
77# OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
78# the document has to name the origin it will be fetched from. Modal's
79# deployed URL is stable for a given app name, which is what makes that safe
80# to write down.
81ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run"
82
83# The OAuth client identity, served at `/client-metadata.json`.
84#
85# This is what makes Bluesky sign-in possible from this origin at all. freeq's
86# auth broker will only redirect to hosts on its own allowlist, and this one is
87# not among them -- but a client that runs the OAuth flow *itself* is not asking
88# the broker for anything. An AT Protocol authorization server fetches this
89# document from the client_id URL and takes it as the authority on where a code
90# may be sent, so the allowlist that matters is the `redirect_uris` below, which
91# we publish.
92#
93# A public client: `token_endpoint_auth_method: none` and no secret, because a
94# page in a browser can keep none. What stands in for one is DPoP -- every token
95# is bound to a key the client proves it holds, which is also exactly what
96# freeq's SASL `pds-oauth` method verifies.
97CLIENT_METADATA = {
98 "client_id": f"{ORIGIN}/client-metadata.json",
99 "client_name": "frq",
100 "client_uri": f"{ORIGIN}/",
101 "redirect_uris": [f"{ORIGIN}/"],
102 "grant_types": ["authorization_code", "refresh_token"],
103 "response_types": ["code"],
104 # `atproto` is the identity scope freeq needs; `transition:generic` is what
105 # a PDS still wants for ordinary reads and writes.
106 "scope": "atproto transition:generic",
107 "token_endpoint_auth_method": "none",
108 "application_type": "web",
109 "dpop_bound_access_tokens": True,
110}
111
112app = modal.App("frq-flutter-web-serve")
113
114image = modal.Image.debian_slim(python_version="3.12")
115
116
117@app.function(
118 image=image,
119 volumes={"/devshell": DEVSHELL},
120 # Nothing here holds state between requests, and a reader who wanders off
121 # should stop costing anything -- so let it go to zero quickly rather than
122 # keeping a container warm for a static directory.
123 scaledown_window=60,
124)
125@modal.web_server(PORT, startup_timeout=30)
126def web():
127 # A Volume mount is a snapshot taken when the container starts, so a
128 # container that outlives a build would keep serving the old one. Reload
129 # first and the newest committed build is what gets served -- which is the
130 # whole point of the split: rebuild in the Sandbox, and the next cold
131 # start here picks it up with nothing redeployed.
132 DEVSHELL.reload()
133
134 # The client metadata is written beside the bundle rather than served by a
135 # route of its own: `http.server` has no routing table, and one file on
136 # disk is less machinery than a handler subclass. Written at start-up and
137 # not baked into the build, because it names the deployed origin, which is
138 # a property of this Function and not of the ClojureDart.
139 #
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago140 # 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()
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago155
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago156 # `http.server` with one route bolted on, rather than `-m http.server`:
157 # static files are still all the app wants on first load, but images need
158 # somewhere same-origin to come from. Written out and run as a file
159 # 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 15h ago160 # 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 + '''
177import 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.
185CACHE = {}
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago186
187
188class H(http.server.SimpleHTTPRequestHandler):
189 def __init__(self, *a, **kw):
190 super().__init__(*a, directory=ROOT, **kw)
191
192 def do_OPTIONS(self):
193 # The preflight. A cross-origin image fetch does not send one, but the
194 # XHRs that read freeq's API do, and answering it is two lines.
195 self.send_response(204)
196 self.send_header("Access-Control-Allow-Origin", "*")
197 self.send_header("Access-Control-Allow-Headers", "*")
198 self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
199 self.end_headers()
200
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago201 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
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago221 def do_GET(self):
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago222 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:
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago260 return super().do_GET()
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago261 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):
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago276 q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
277 target = (q.get("url") or [""])[0]
278 parts = urllib.parse.urlparse(target)
279 # Allowlisted https hosts only. Anything else and this is an open
280 # relay wearing our origin.
281 if parts.scheme != "https" or parts.hostname not in HOSTS:
282 self.send_error(403, "host not proxied")
283 return
284 try:
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago285 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 ago286 with urllib.request.urlopen(req, timeout=30) as up:
287 body = up.read()
288 ctype = up.headers.get("content-type", "application/octet-stream")
289 except Exception as e:
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago290 self.send_error(502, f"upstream: {e}")
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago291 return
292 self.send_response(200)
293 self.send_header("Content-Type", ctype)
294 self.send_header("Content-Length", str(len(body)))
295 # The whole point: our origin says yes where the far side said nothing.
296 self.send_header("Access-Control-Allow-Origin", "*")
297 self.send_header("Cache-Control", "public, max-age=3600")
298 self.end_headers()
299 self.wfile.write(body)
300
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago301
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago302class S(socketserver.ThreadingTCPServer):
303 # Threaded, because a proxied fetch blocks: one slow avatar must not stop
304 # the page loading. daemon_threads so the process can still exit.
305 allow_reuse_address = True
306 daemon_threads = True
307
Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago308
309S(("", PORT), H).serve_forever()
310'''
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago311 with open("/tmp/frq_serve.py", "w") as f:
312 f.write(server)
313
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago314 # Popen and not run: `@modal.web_server` expects the body to *start* a
315 # server and return, so Modal can begin proxying. Blocking here would time
316 # out at startup_timeout with nothing ever listening.
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago317 subprocess.Popen(["python", "/tmp/frq_serve.py"])