| 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 | |
| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 49 | # 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, so |
| 51 | # the document has to name the origin it will be fetched from. Modal's |
| 52 | # deployed URL is stable for a given app name, which is what makes that safe |
| 53 | # to write down. |
| 54 | ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run" |
| 55 | |
| 56 | # The OAuth client identity, served at `/client-metadata.json`. |
| 57 | # |
| 58 | # This is what makes Bluesky sign-in possible from this origin at all. freeq's |
| 59 | # auth broker will only redirect to hosts on its own allowlist, and this one is |
| 60 | # not among them -- but a client that runs the OAuth flow *itself* is not asking |
| 61 | # the broker for anything. An AT Protocol authorization server fetches this |
| 62 | # document from the client_id URL and takes it as the authority on where a code |
| 63 | # may be sent, so the allowlist that matters is the `redirect_uris` below, which |
| 64 | # we publish. |
| 65 | # |
| 66 | # A public client: `token_endpoint_auth_method: none` and no secret, because a |
| 67 | # page in a browser can keep none. What stands in for one is DPoP -- every token |
| 68 | # is bound to a key the client proves it holds, which is also exactly what |
| 69 | # freeq's SASL `pds-oauth` method verifies. |
| 70 | CLIENT_METADATA = { |
| 71 | "client_id": f"{ORIGIN}/client-metadata.json", |
| 72 | "client_name": "frq", |
| 73 | "client_uri": f"{ORIGIN}/", |
| 74 | "redirect_uris": [f"{ORIGIN}/"], |
| 75 | "grant_types": ["authorization_code", "refresh_token"], |
| 76 | "response_types": ["code"], |
| 77 | # `atproto` is the identity scope freeq needs; `transition:generic` is what |
| 78 | # a PDS still wants for ordinary reads and writes. |
| 79 | "scope": "atproto transition:generic", |
| 80 | "token_endpoint_auth_method": "none", |
| 81 | "application_type": "web", |
| 82 | "dpop_bound_access_tokens": True, |
| 83 | } |
| 84 | |
| 85 | app = modal.App("frq-flutter-web-serve") |
| 86 | |
| 87 | image = modal.Image.debian_slim(python_version="3.12") |
| 88 | |
| 89 | |
| 90 | @app.function( |
| 91 | image=image, |
| 92 | volumes={"/devshell": DEVSHELL}, |
| 93 | # Nothing here holds state between requests, and a reader who wanders off |
| 94 | # should stop costing anything -- so let it go to zero quickly rather than |
| 95 | # keeping a container warm for a static directory. |
| 96 | scaledown_window=60, |
| 97 | ) |
| 98 | @modal.web_server(PORT, startup_timeout=30) |
| 99 | def web(): |
| 100 | # A Volume mount is a snapshot taken when the container starts, so a |
| 101 | # container that outlives a build would keep serving the old one. Reload |
| 102 | # first and the newest committed build is what gets served -- which is the |
| 103 | # whole point of the split: rebuild in the Sandbox, and the next cold |
| 104 | # start here picks it up with nothing redeployed. |
| 105 | DEVSHELL.reload() |
| 106 | |
| 107 | # The client metadata is written beside the bundle rather than served by a |
| 108 | # route of its own: `http.server` has no routing table, and one file on |
| 109 | # disk is less machinery than a handler subclass. Written at start-up and |
| 110 | # not baked into the build, because it names the deployed origin, which is |
| 111 | # a property of this Function and not of the ClojureDart. |
| 112 | # |
| 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() |
| 119 | |
| Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago | 120 | # `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 need |
| 122 | # 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. |
| 124 | server = f""" |
| 125 | import http.server, os, socketserver, urllib.parse, urllib.request |
| 126 | |
| 127 | ROOT = {WEB_ROOT!r} |
| 128 | HOSTS = {PROXY_HOSTS!r} |
| 129 | |
| 130 | class H(http.server.SimpleHTTPRequestHandler): |
| 131 | def __init__(self, *a, **kw): |
| 132 | super().__init__(*a, directory=ROOT, **kw) |
| 133 | |
| 134 | def do_OPTIONS(self): |
| 135 | # The preflight. A cross-origin image fetch does not send one, but the |
| 136 | # XHRs that read freeq's API do, and answering it is two lines. |
| 137 | self.send_response(204) |
| 138 | self.send_header("Access-Control-Allow-Origin", "*") |
| 139 | self.send_header("Access-Control-Allow-Headers", "*") |
| 140 | self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") |
| 141 | self.end_headers() |
| 142 | |
| 143 | def do_GET(self): |
| 144 | if not self.path.startswith("/proxy?"): |
| 145 | return super().do_GET() |
| 146 | q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) |
| 147 | target = (q.get("url") or [""])[0] |
| 148 | parts = urllib.parse.urlparse(target) |
| 149 | # Allowlisted https hosts only. Anything else and this is an open |
| 150 | # relay wearing our origin. |
| 151 | if parts.scheme != "https" or parts.hostname not in HOSTS: |
| 152 | self.send_error(403, "host not proxied") |
| 153 | return |
| 154 | try: |
| 155 | req = urllib.request.Request(target, headers={{"user-agent": "frq"}}) |
| 156 | with urllib.request.urlopen(req, timeout=30) as up: |
| 157 | body = up.read() |
| 158 | ctype = up.headers.get("content-type", "application/octet-stream") |
| 159 | except Exception as e: |
| 160 | self.send_error(502, f"upstream: {{e}}") |
| 161 | return |
| 162 | self.send_response(200) |
| 163 | self.send_header("Content-Type", ctype) |
| 164 | self.send_header("Content-Length", str(len(body))) |
| 165 | # The whole point: our origin says yes where the far side said nothing. |
| 166 | self.send_header("Access-Control-Allow-Origin", "*") |
| 167 | self.send_header("Cache-Control", "public, max-age=3600") |
| 168 | self.end_headers() |
| 169 | self.wfile.write(body) |
| 170 | |
| 171 | class S(socketserver.ThreadingTCPServer): |
| 172 | # Threaded, because a proxied fetch blocks: one slow avatar must not stop |
| 173 | # the page loading. daemon_threads so the process can still exit. |
| 174 | allow_reuse_address = True |
| 175 | daemon_threads = True |
| 176 | |
| 177 | S(("", {PORT}), H).serve_forever() |
| 178 | """ |
| 179 | with open("/tmp/frq_serve.py", "w") as f: |
| 180 | f.write(server) |
| 181 | |
| A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago | 182 | # Popen and not run: `@modal.web_server` expects the body to *start* a |
| 183 | # server and return, so Modal can begin proxying. Blocking here would time |
| 184 | # 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 | 185 | subprocess.Popen(["python", "/tmp/frq_serve.py"]) |