| 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 | |
| 35 | # This app's own origin, and the one thing here that is not derivable: an |
| 36 | # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so |
| 37 | # the document has to name the origin it will be fetched from. Modal's |
| 38 | # deployed URL is stable for a given app name, which is what makes that safe |
| 39 | # to write down. |
| 40 | ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run" |
| 41 | |
| 42 | # The OAuth client identity, served at `/client-metadata.json`. |
| 43 | # |
| 44 | # This is what makes Bluesky sign-in possible from this origin at all. freeq's |
| 45 | # auth broker will only redirect to hosts on its own allowlist, and this one is |
| 46 | # not among them -- but a client that runs the OAuth flow *itself* is not asking |
| 47 | # the broker for anything. An AT Protocol authorization server fetches this |
| 48 | # document from the client_id URL and takes it as the authority on where a code |
| 49 | # may be sent, so the allowlist that matters is the `redirect_uris` below, which |
| 50 | # we publish. |
| 51 | # |
| 52 | # A public client: `token_endpoint_auth_method: none` and no secret, because a |
| 53 | # page in a browser can keep none. What stands in for one is DPoP -- every token |
| 54 | # is bound to a key the client proves it holds, which is also exactly what |
| 55 | # freeq's SASL `pds-oauth` method verifies. |
| 56 | CLIENT_METADATA = { |
| 57 | "client_id": f"{ORIGIN}/client-metadata.json", |
| 58 | "client_name": "frq", |
| 59 | "client_uri": f"{ORIGIN}/", |
| 60 | "redirect_uris": [f"{ORIGIN}/"], |
| 61 | "grant_types": ["authorization_code", "refresh_token"], |
| 62 | "response_types": ["code"], |
| 63 | # `atproto` is the identity scope freeq needs; `transition:generic` is what |
| 64 | # a PDS still wants for ordinary reads and writes. |
| 65 | "scope": "atproto transition:generic", |
| 66 | "token_endpoint_auth_method": "none", |
| 67 | "application_type": "web", |
| 68 | "dpop_bound_access_tokens": True, |
| 69 | } |
| 70 | |
| 71 | app = modal.App("frq-flutter-web-serve") |
| 72 | |
| 73 | image = modal.Image.debian_slim(python_version="3.12") |
| 74 | |
| 75 | |
| 76 | @app.function( |
| 77 | image=image, |
| 78 | volumes={"/devshell": DEVSHELL}, |
| 79 | # Nothing here holds state between requests, and a reader who wanders off |
| 80 | # should stop costing anything -- so let it go to zero quickly rather than |
| 81 | # keeping a container warm for a static directory. |
| 82 | scaledown_window=60, |
| 83 | ) |
| 84 | @modal.web_server(PORT, startup_timeout=30) |
| 85 | def web(): |
| 86 | # A Volume mount is a snapshot taken when the container starts, so a |
| 87 | # container that outlives a build would keep serving the old one. Reload |
| 88 | # first and the newest committed build is what gets served -- which is the |
| 89 | # whole point of the split: rebuild in the Sandbox, and the next cold |
| 90 | # start here picks it up with nothing redeployed. |
| 91 | DEVSHELL.reload() |
| 92 | |
| 93 | # The client metadata is written beside the bundle rather than served by a |
| 94 | # route of its own: `http.server` has no routing table, and one file on |
| 95 | # disk is less machinery than a handler subclass. Written at start-up and |
| 96 | # not baked into the build, because it names the deployed origin, which is |
| 97 | # a property of this Function and not of the ClojureDart. |
| 98 | # |
| 99 | # The volume is shared and durable, so this also survives for the next |
| 100 | # cold start; rewriting it every time is what keeps ORIGIN and the file in |
| 101 | # step when one of them changes. |
| 102 | with open(f"{WEB_ROOT}/client-metadata.json", "w") as f: |
| 103 | json.dump(CLIENT_METADATA, f, indent=2) |
| 104 | DEVSHELL.commit() |
| 105 | |
| 106 | # Popen and not run: `@modal.web_server` expects the body to *start* a |
| 107 | # server and return, so Modal can begin proxying. Blocking here would time |
| 108 | # out at startup_timeout with nothing ever listening. |
| 109 | # |
| 110 | # `python -m http.server` and not something with a routing table: Flutter |
| 111 | # writes a service worker and an index.html and asks for its own assets by |
| 112 | # exact path, so plain static serving is all the app wants on first load. |
| 113 | # What it does not give is a fallback for a deep link -- Flutter's default |
| 114 | # URL strategy is real paths, so /chats reaches the server rather than the |
| 115 | # router and gets a 404. That wants a real fallback-to-index server, and |
| 116 | # is worth having only once there is a router out there to reach. |
| 117 | subprocess.Popen( |
| 118 | ["python", "-m", "http.server", str(PORT), "--directory", WEB_ROOT], |
| 119 | ) |