| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 1 | """The built web app, served from the volume the container built it into. |
| 2 | |
| 3 | Not part of `container.py`, and not a key in `container.toml`, because it is |
| 4 | the 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 |
| 6 | directory of static files that outlives it. A Function mounting the same |
| 7 | volume can hand those out without rebuilding anything, and can scale to zero |
| 8 | between 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 | |
| 13 | Deliberately NOT built on the container's own image. That image carries the |
| 14 | repo and a few gigabytes of devShell closure, all of it for a compile that |
| 15 | has already happened somewhere else; the bytes this serves come off the |
| 16 | volume, so the image needs a python and nothing more. Cold starts are the |
| 17 | difference. |
| 18 | """ |
| 19 | |
| 20 | import json |
| 21 | import subprocess |
| 22 | |
| 23 | import 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. |
| 27 | DEVSHELL = 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. |
| 31 | WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web" |
| 32 | |
| 33 | PORT = 8080 |
| 34 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 35 | # 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. |
| 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") |
| 48 | |
| An hour of cache is a day of debugging 562900f nandi 10h ago | 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. |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 56 | # |
| An hour of cache is a day of debugging 562900f nandi 10h ago | 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. |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 62 | REVALIDATE = ( |
| 63 | "index.html", |
| An hour of cache is a day of debugging 562900f nandi 10h ago | 64 | "main.dart.js", |
| 65 | "flutter.js", |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 66 | "flutter_bootstrap.js", |
| 67 | "flutter_service_worker.js", |
| An hour of cache is a day of debugging 562900f nandi 10h ago | 68 | "manifest.json", |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 69 | "version.json", |
| 70 | "client-metadata.json", |
| 71 | ) |
| 72 | |
| An hour of cache is a day of debugging 562900f nandi 10h ago | 73 | # Prefixes that keep the bounded max-age, by published path. |
| 74 | LONG_CACHE = ("canvaskit/",) |
| 75 | |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 76 | MAX_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. |
| 80 | COMPRESSIBLE = ( |
| 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 ago | 88 | # 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. |
| 93 | ORIGIN = "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. |
| 109 | CLIENT_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 | |
| 124 | app = modal.App("frq-flutter-web-serve") |
| 125 | |
| 126 | image = 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) |
| 138 | def 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 11h ago | 152 | # 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 ago | 167 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 168 | # `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 11h ago | 172 | # 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 10h ago | 180 | f"LONG_CACHE = {LONG_CACHE!r}\n" |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 181 | 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 + ''' |
| 190 | import 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. |
| 198 | CACHE = {} |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 199 | |
| 200 | |
| 201 | class 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 11h ago | 214 | 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 10h ago | 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" |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 224 | |
| 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 ago | 240 | def do_GET(self): |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 241 | 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 ago | 279 | return super().do_GET() |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 11h ago | 280 | 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 ago | 295 | 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 11h ago | 304 | 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 ago | 305 | 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 11h ago | 309 | self.send_error(502, f"upstream: {e}") |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 310 | 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 11h ago | 320 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 321 | class 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 11h ago | 327 | |
| 328 | S(("", PORT), H).serve_forever() |
| 329 | ''' |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 330 | 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 ago | 333 | # 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 ago | 336 | subprocess.Popen(["python", "/tmp/frq_serve.py"]) |