[container] name = "frq-web" description = "the Flutter web build, incremental, from a pinned toolchain" # `debian:13-slim` and not `arch-nix`: there is no nix in this container any # more. The build is `tools/build-web.sh`, which fetches its own Flutter, JDK # and Clojure CLI by pinned sha256, so what the image owes it is curl, git, # tar and a C compiler — and the smallest image that has them is the right one. # # gcc for the `cc` that `require_host_tools` gates on. This container never # compiles Nim, but the check is one gate for every toolchain user rather than # one per subcommand, and a web build without a compiler is what failed CI: # `toolchain: this needs cc on PATH and cannot fetch them`, and then flutter # not found, because the gate exits before anything is fetched. registry = "debian:13-slim" # A Sandbox, not a Function: it runs on a real VM, the command is the # sandbox's own process so it dies when the command does, and only a Sandbox # can hold open a tunnel -- which is the whole of `serve`. runtime = "sandbox" [build] # The container lives inside the repo it builds, so the copy is rooted two # levels up and `.` is the whole tree. context = "../.." include = ["."] # The whole image build, and it is one apt line. What used to be here -- a # nix store to populate, a devShell to print, a closure to warm before the # source arrived so an edit would not invalidate it -- is gone with the nix # it was for. There is no `warm` list any more either: this step reads # nothing out of the tree, so nothing in the tree can invalidate it. # # git, because Flutter shells out to it against its own SDK checkout and # refuses to run without one; unzip and xz-utils, because that is what the # SDK and the toolchain tarballs arrive as; rsync for the sync below; # ca-certificates so curl can verify what it fetches. setup = [ "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gcc git rsync tar unzip xz-utils && rm -rf /var/lib/apt/lists/*", ] # The build state a local checkout carries, wanted by nothing out here: this # container builds into a volume of its own, and the clojure caches # are the laptop's. ignore = [ "flutter/build", "flutter/.home", "flutter/.dart_tool", "flutter/.clojuredart", "flutter/.cpcache", # The ClojureDart compiler's output, gitignored and -- until now -- # uploaded anyway, because this list is explicit and does not read # .gitignore. It is megabytes of generated Dart per run, and worse than # the upload is what happened on arrival: the rsync below overwrote the # volume's copy, the one the last container compiled, with a laptop's. # Every file whose content differed then looked new to the compiler and # to Flutter, which is the incremental build undone by the thing that was # meant to feed it. It belongs to the volume, like flutter/build. "flutter/lib/cljd-out", # The toolchain, which is a gigabyte of Flutter SDK and lives on the # volume out here. ".toolchain", ".cpcache", "result", "build", ".git", # An editor's linter rewrites this while the upload is reading it, and # Modal fails the whole run with "was modified during build process". # Nothing out here reads it. ".clj-kondo", ] # One volume now, where there were two: the nix binary cache went with nix. # `devshell` is the working state of an incremental loop, shared by every # container that has one -- each gets its own directory under it, named for # what it belongs to, so `web` and `flutter-desktop` never write the # same tree. Modal Volumes have no locking, so those directory names are the # only thing keeping them apart, and two runs of the *same* container must # not overlap. [volumes] devshell = "/devshell" [resources] cpu = 8 memory = 16384 timeout = 3600 # The port `tools/build-web.sh serve` listens on, tunnelled out. Nothing is # served unless the command asks for it -- a plain build exits and the tunnel # closes with the sandbox -- but the port has to be declared at create time, # so it is declared once here and `modal run --command` decides whether # anything ever binds it. [network] ports = [8080] [run] workdir = "/app" # Source in, toolchain out of the volume, build in place. Three steps, and # none of them evaluates anything: the old command spent its first minutes # entering a devShell, printing an environment, caching that environment # against flake.lock and copying a nix closure back afterwards, all to arrive # at a PATH. A PATH is what `tools/toolchain.sh env` prints, out of a # directory that is already on the volume. command = """ set -e SHELL_DIR=/devshell/frq-web # Beside the working tree and NOT inside it: the rsync below runs with # --delete, so anything under $SHELL_DIR that is not in /app is removed on # every run. A cache kept in there would be deleted moments before it was # consulted -- which is what happened to the last one that tried. export FRQ_TOOLCHAIN=/devshell/frq-web.toolchain mkdir -p "$SHELL_DIR" "$FRQ_TOOLCHAIN" echo "sync: /app -> $SHELL_DIR" # rsync and not cp, with --checksum and not mtimes: Modal copies the source in # with fresh timestamps every run, so a plain copy looks entirely new to # Flutter and rebuilds the lot. --checksum compares content and leaves the # unchanged files' timestamps alone, which is the whole basis of the # incremental build. # # The excludes are the state we are here to keep -- overwriting them from /app # would defeat the volume. `flutter/web/` is NOT on the list: it is committed, # because the OAuth client keeps a script there, so it has to arrive from # /app like any other source. rsync -a --checksum --delete \ --exclude 'flutter/.home/' \ --exclude 'flutter/.clojuredart/' \ --exclude 'flutter/build/' \ --exclude 'flutter/lib/cljd-out/' \ --exclude 'flutter/.dart_tool/' \ --exclude '.toolchain/' \ --exclude '.git' \ /app/ "$SHELL_DIR/" cd "$SHELL_DIR" # What survived from the last run, by presence and not by size: `du` here # walked the pub cache, the toolchain and every object of the last build over # a network volume, for numbers nobody acts on. for d in "$FRQ_TOOLCHAIN" flutter/.clojuredart flutter/lib/cljd-out flutter/build; do [ -d "$d" ] && echo " carried over: $d" done # The same script `just build web` runs, with the same single build mode: # what is served and what a laptop compiles are the same bundle. tools/build-web.sh build """ # Flutter keeps its settings -- `--enable-web` among them -- under # XDG_CONFIG_HOME, and its own caches under XDG_CACHE_HOME. Both point into # the volume so a second run finds what the first one decided. Set here # rather than in the command so a shell into this container gets them too. env = { XDG_CACHE_HOME = "/devshell/frq-web.toolchain/.cache", XDG_CONFIG_HOME = "/devshell/frq-web.toolchain/.config", FRQ_TOOLCHAIN = "/devshell/frq-web.toolchain" }