| 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 | |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago | 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 | |
| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 76 | # 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. |
| 81 | ORIGIN = "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. |
| 97 | CLIENT_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 | |
| 112 | app = modal.App("frq-flutter-web-serve") |
| 113 | |
| 114 | image = 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) |
| 126 | def 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 ago | 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() |
| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 155 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 156 | # `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 ago | 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 = {} |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 186 | |
| 187 | |
| 188 | class 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 ago | 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 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 221 | def do_GET(self): |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago | 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: |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 260 | return super().do_GET() |
| Ten megabytes down to four, and a cold start that was a round trip ec5b47d nandi 15h ago | 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): |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 276 | 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 ago | 285 | 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 | 286 | 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 ago | 290 | self.send_error(502, f"upstream: {e}") |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 291 | 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 ago | 301 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 302 | class 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 ago | 308 | |
| 309 | S(("", PORT), H).serve_forever() |
| 310 | ''' |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 311 | 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 ago | 314 | # 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 ago | 317 | subprocess.Popen(["python", "/tmp/frq_serve.py"]) |