1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
"""The built web app, served from the volume the container built it into.
Not part of `container.py`, and not a key in `container.toml`, because it is
the other half of a split the build already makes: the Sandbox compiles into
`/devshell/frq-flutter-web` and exits, and what it leaves behind is a
directory of static files that outlives it. A Function mounting the same
volume can hand those out without rebuilding anything, and can scale to zero
between readers -- which a Sandbox holding a tunnel open cannot.
modal serve .modal/flutter-web/serve.py # while editing, auto-reloads
modal deploy .modal/flutter-web/serve.py # a URL that stays
Deliberately NOT built on the container's own image. That image carries the
repo and a few gigabytes of devShell closure, all of it for a compile that
has already happened somewhere else; the bytes this serves come off the
volume, so the image needs a python and nothing more. Cold starts are the
difference.
"""
import json
import subprocess
import modal
# The same volume the container writes to, named the same way. `from_name` is
# lazy, so naming it here costs nothing until a container actually mounts it.
DEVSHELL = modal.Volume.from_name("devshell", create_if_missing=True)
# Where `just flutter-web` leaves its output, under this devShell's own
# directory on the shared volume -- the same path container.toml builds in.
WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web"
PORT = 8080
# This app's own origin, and the one thing here that is not derivable: an
# OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
# the document has to name the origin it will be fetched from. Modal's
# deployed URL is stable for a given app name, which is what makes that safe
# to write down.
ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run"
# The OAuth client identity, served at `/client-metadata.json`.
#
# This is what makes Bluesky sign-in possible from this origin at all. freeq's
# auth broker will only redirect to hosts on its own allowlist, and this one is
# not among them -- but a client that runs the OAuth flow *itself* is not asking
# the broker for anything. An AT Protocol authorization server fetches this
# document from the client_id URL and takes it as the authority on where a code
# may be sent, so the allowlist that matters is the `redirect_uris` below, which
# we publish.
#
# A public client: `token_endpoint_auth_method: none` and no secret, because a
# page in a browser can keep none. What stands in for one is DPoP -- every token
# is bound to a key the client proves it holds, which is also exactly what
# freeq's SASL `pds-oauth` method verifies.
CLIENT_METADATA = {
"client_id": f"{ORIGIN}/client-metadata.json",
"client_name": "frq",
"client_uri": f"{ORIGIN}/",
"redirect_uris": [f"{ORIGIN}/"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
# `atproto` is the identity scope freeq needs; `transition:generic` is what
# a PDS still wants for ordinary reads and writes.
"scope": "atproto transition:generic",
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": True,
}
app = modal.App("frq-flutter-web-serve")
image = modal.Image.debian_slim(python_version="3.12")
@app.function(
image=image,
volumes={"/devshell": DEVSHELL},
# Nothing here holds state between requests, and a reader who wanders off
# should stop costing anything -- so let it go to zero quickly rather than
# keeping a container warm for a static directory.
scaledown_window=60,
)
@modal.web_server(PORT, startup_timeout=30)
def web():
# A Volume mount is a snapshot taken when the container starts, so a
# container that outlives a build would keep serving the old one. Reload
# first and the newest committed build is what gets served -- which is the
# whole point of the split: rebuild in the Sandbox, and the next cold
# start here picks it up with nothing redeployed.
DEVSHELL.reload()
# The client metadata is written beside the bundle rather than served by a
# route of its own: `http.server` has no routing table, and one file on
# disk is less machinery than a handler subclass. Written at start-up and
# not baked into the build, because it names the deployed origin, which is
# a property of this Function and not of the ClojureDart.
#
# The volume is shared and durable, so this also survives for the next
# cold start; rewriting it every time is what keeps ORIGIN and the file in
# step when one of them changes.
with open(f"{WEB_ROOT}/client-metadata.json", "w") as f:
json.dump(CLIENT_METADATA, f, indent=2)
DEVSHELL.commit()
# Popen and not run: `@modal.web_server` expects the body to *start* a
# server and return, so Modal can begin proxying. Blocking here would time
# out at startup_timeout with nothing ever listening.
#
# `python -m http.server` and not something with a routing table: Flutter
# writes a service worker and an index.html and asks for its own assets by
# exact path, so plain static serving is all the app wants on first load.
# What it does not give is a fallback for a deep link -- Flutter's default
# URL strategy is real paths, so /chats reaches the server rather than the
# router and gets a 404. That wants a real fallback-to-index server, and
# is worth having only once there is a router out there to reach.
subprocess.Popen(
["python", "-m", "http.server", str(PORT), "--directory", WEB_ROOT],
)
|