A third target, and the seam that was already waiting for it
`flutter build web` over the same ClojureDart the APK and the Linux bundle compile: one `clojure -M:cljd compile` from `frq.main-web`, dart2js instead of CMake, a directory of static files instead of a runner. The entry point is a second namespace rather than a branch, because the compiler decides: `compile` walks out from one namespace, so what `frq.main` requires is what every target builds — and `frq.io.web` names `dart:html`, which Android and Linux have no library for. `frq.main` splits into `bind!` and `start!`; each entry installs its own host and calls them. `frq.io.web` is the third backend behind `frq.io`, and the one that shows what the seam was for: a browser has no directory, so the paths `frq.store` hands over are not opened, they are spelled — `config-dir` is a prefix and every file under it is one localStorage key. `.modal/flutter-web` builds it, and `_loader.py` grows `[build] warm`/`setup` so that image building and program building stop being the same thing: the toolchain layer is keyed on the six files the flake actually evaluates, and editing ClojureDart no longer re-warms a devShell that did not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f54ca45 parent: e853593 modified
.gitignore +3 -0 | @@ -22,3 +22,6 @@ | ||
| 22 | 22 | |
| 23 | 23 | # Python bytecode, from the loader the .modal/ containers import. |
| 24 | 24 | __pycache__/ |
| 25 | + | |
| 26 | +# The mirror `just web-local` keeps of the Modal-built web bundle. | |
| 27 | +.web-local/ | |
| @@ -22,3 +22,6 @@ | |||
| 22 | 22 | ||
| 23 | # Python bytecode, from the loader the .modal/ containers import. | 23 | # Python bytecode, from the loader the .modal/ containers import. |
| 24 | __pycache__/ | 24 | __pycache__/ |
| 25 | + | ||
| 26 | +# The mirror `just web-local` keeps of the Modal-built web bundle. | ||
| 27 | +.web-local/ | ||
modified
.modal/_loader.py +56 -0 | @@ -74,6 +74,16 @@ class Container: | ||
| 74 | 74 | # directory, a dataset too big to bake in. |
| 75 | 75 | self.volume_spec = dict(spec.get("volumes", {})) |
| 76 | 76 | |
| 77 | + # Ports to tunnel out of a Sandbox, from [network] ports. Encrypted, | |
| 78 | + # which is Modal's own word for "the tunnel terminates TLS and speaks | |
| 79 | + # plain HTTP to your process" -- so the thing listening inside is an | |
| 80 | + # ordinary http.server and not something holding a certificate. | |
| 81 | + # | |
| 82 | + # Sandbox-only: a Function has no long-lived process to tunnel into, | |
| 83 | + # and `modal run` on one would hand back a URL for a container that | |
| 84 | + # has already exited. | |
| 85 | + self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])] | |
| 86 | + | |
| 77 | 87 | # Modal re-imports this module inside the container, so everything |
| 78 | 88 | # below runs twice: once here, once out there. Out there the local |
| 79 | 89 | # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and |
| @@ -125,6 +135,11 @@ class Container: | ||
| 125 | 135 | ) |
| 126 | 136 | if self.use_shim and not os.path.exists(PTYSHIM_C): |
| 127 | 137 | raise SpecError(f"[nix] shim = true but {PTYSHIM_C} is missing") |
| 138 | + if self.ports and self.runtime != "sandbox": | |
| 139 | + raise SpecError( | |
| 140 | + "[network] ports needs [container] runtime = \"sandbox\" --" | |
| 141 | + " a Function has no process to tunnel into" | |
| 142 | + ) | |
| 128 | 143 | for name, mount in self.volume_spec.items(): |
| 129 | 144 | if not isinstance(mount, str) or not mount.startswith("/"): |
| 130 | 145 | raise SpecError( |
| @@ -226,6 +241,31 @@ class Container: | ||
| 226 | 241 | # the container directory -- a container that builds the repo it lives |
| 227 | 242 | # in sets context = "../..", so include = ["."] means the whole repo. |
| 228 | 243 | context = os.path.normpath(os.path.join(self.dir, build.get("context", "."))) |
| 244 | + | |
| 245 | + # Image building and program building, kept apart. | |
| 246 | + # | |
| 247 | + # `[build] commands` run AFTER the source copy, so any edit anywhere in | |
| 248 | + # the tree invalidates them -- and for a container whose commands warm | |
| 249 | + # a devShell, that means minutes of nix on every iteration of a | |
| 250 | + # one-line change. `warm` + `setup` are the same two steps moved in | |
| 251 | + # front of the source: `warm` names only the files the flake actually | |
| 252 | + # evaluates (its own, and whatever the devShell's derivations read), | |
| 253 | + # and `setup` runs against those alone. Editing `flutter/src` then | |
| 254 | + # invalidates nothing above the final copy, and the toolchain layer is | |
| 255 | + # reused until a dependency moves. | |
| 256 | + # | |
| 257 | + # Volumes are mounted here for `commands`' reason: a step that can | |
| 258 | + # reach the binary cache never has to build, which is the one thing | |
| 259 | + # gVisor will not do. | |
| 260 | + for rel in build.get("warm", []): | |
| 261 | + src = os.path.normpath(os.path.join(context, rel)) | |
| 262 | + dest = f"{self.workdir}/{rel}" | |
| 263 | + if os.path.isdir(src): | |
| 264 | + image = image.add_local_dir(src, dest, copy=True) | |
| 265 | + else: | |
| 266 | + image = image.add_local_file(src, dest, copy=True) | |
| 267 | + if setup := build.get("setup", []): | |
| 268 | + image = image.run_commands(*setup, volumes=self.volumes) | |
| 229 | 269 | # `ignore` is what keeps a build tree out of the image. A checkout that |
| 230 | 270 | # has been built in locally carries its output -- flutter/build and the |
| 231 | 271 | # caches beside it were 395MB of a 441MB repo -- and all of it would be |
| @@ -341,6 +381,8 @@ class Container: | ||
| 341 | 381 | kwargs["experimental_options"] = dict(opts) |
| 342 | 382 | if volumes := self.volumes: |
| 343 | 383 | kwargs["volumes"] = volumes |
| 384 | + if self.ports: | |
| 385 | + kwargs["encrypted_ports"] = list(self.ports) | |
| 344 | 386 | return kwargs |
| 345 | 387 | |
| 346 | 388 | def shell_command(self, override: str = "") -> str: |
| @@ -376,6 +418,7 @@ class Container: | ||
| 376 | 418 | env={k: str(v) for k, v in self.env.items()}, |
| 377 | 419 | **self.sandbox_kwargs, |
| 378 | 420 | ) |
| 421 | + self._print_tunnels(sb) | |
| 379 | 422 | sb.wait() |
| 380 | 423 | if sb.returncode != 0: |
| 381 | 424 | raise RuntimeError( |
| @@ -384,6 +427,19 @@ class Container: | ||
| 384 | 427 | ) |
| 385 | 428 | return "" |
| 386 | 429 | |
| 430 | + def _print_tunnels(self, sb: "modal.Sandbox") -> None: | |
| 431 | + """Say where a tunnelled port can be reached, once the sandbox is up. | |
| 432 | + | |
| 433 | + `tunnels()` blocks until the Sandbox is scheduled, which is why this | |
| 434 | + is called after create and not folded into it. Nothing prints when | |
| 435 | + [network] ports is empty, which is every container but the serving | |
| 436 | + ones. | |
| 437 | + """ | |
| 438 | + if not self.ports: | |
| 439 | + return | |
| 440 | + for port, tunnel in sb.tunnels().items(): | |
| 441 | + print(f" :{port} -> {tunnel.url}") | |
| 442 | + | |
| 387 | 443 | def open_sandbox(self) -> "modal.Sandbox": |
| 388 | 444 | """Start a Sandbox and leave it running, for `scripts/shell`. |
| 389 | 445 | |
| @@ -74,6 +74,16 @@ class Container: | |||
| 74 | # directory, a dataset too big to bake in. | 74 | # directory, a dataset too big to bake in. |
| 75 | self.volume_spec = dict(spec.get("volumes", {})) | 75 | self.volume_spec = dict(spec.get("volumes", {})) |
| 76 | 76 | ||
| 77 | + # Ports to tunnel out of a Sandbox, from [network] ports. Encrypted, | ||
| 78 | + # which is Modal's own word for "the tunnel terminates TLS and speaks | ||
| 79 | + # plain HTTP to your process" -- so the thing listening inside is an | ||
| 80 | + # ordinary http.server and not something holding a certificate. | ||
| 81 | + # | ||
| 82 | + # Sandbox-only: a Function has no long-lived process to tunnel into, | ||
| 83 | + # and `modal run` on one would hand back a URL for a container that | ||
| 84 | + # has already exited. | ||
| 85 | + self.ports = [int(p) for p in spec.get("network", {}).get("ports", [])] | ||
| 86 | + | ||
| 77 | # Modal re-imports this module inside the container, so everything | 87 | # Modal re-imports this module inside the container, so everything |
| 78 | # below runs twice: once here, once out there. Out there the local | 88 | # below runs twice: once here, once out there. Out there the local |
| 79 | # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and | 89 | # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and |
| @@ -125,6 +135,11 @@ class Container: | |||
| 125 | ) | 135 | ) |
| 126 | if self.use_shim and not os.path.exists(PTYSHIM_C): | 136 | if self.use_shim and not os.path.exists(PTYSHIM_C): |
| 127 | raise SpecError(f"[nix] shim = true but {PTYSHIM_C} is missing") | 137 | raise SpecError(f"[nix] shim = true but {PTYSHIM_C} is missing") |
| 138 | + if self.ports and self.runtime != "sandbox": | ||
| 139 | + raise SpecError( | ||
| 140 | + "[network] ports needs [container] runtime = \"sandbox\" --" | ||
| 141 | + " a Function has no process to tunnel into" | ||
| 142 | + ) | ||
| 128 | for name, mount in self.volume_spec.items(): | 143 | for name, mount in self.volume_spec.items(): |
| 129 | if not isinstance(mount, str) or not mount.startswith("/"): | 144 | if not isinstance(mount, str) or not mount.startswith("/"): |
| 130 | raise SpecError( | 145 | raise SpecError( |
| @@ -226,6 +241,31 @@ class Container: | |||
| 226 | # the container directory -- a container that builds the repo it lives | 241 | # the container directory -- a container that builds the repo it lives |
| 227 | # in sets context = "../..", so include = ["."] means the whole repo. | 242 | # in sets context = "../..", so include = ["."] means the whole repo. |
| 228 | context = os.path.normpath(os.path.join(self.dir, build.get("context", "."))) | 243 | context = os.path.normpath(os.path.join(self.dir, build.get("context", "."))) |
| 244 | + | ||
| 245 | + # Image building and program building, kept apart. | ||
| 246 | + # | ||
| 247 | + # `[build] commands` run AFTER the source copy, so any edit anywhere in | ||
| 248 | + # the tree invalidates them -- and for a container whose commands warm | ||
| 249 | + # a devShell, that means minutes of nix on every iteration of a | ||
| 250 | + # one-line change. `warm` + `setup` are the same two steps moved in | ||
| 251 | + # front of the source: `warm` names only the files the flake actually | ||
| 252 | + # evaluates (its own, and whatever the devShell's derivations read), | ||
| 253 | + # and `setup` runs against those alone. Editing `flutter/src` then | ||
| 254 | + # invalidates nothing above the final copy, and the toolchain layer is | ||
| 255 | + # reused until a dependency moves. | ||
| 256 | + # | ||
| 257 | + # Volumes are mounted here for `commands`' reason: a step that can | ||
| 258 | + # reach the binary cache never has to build, which is the one thing | ||
| 259 | + # gVisor will not do. | ||
| 260 | + for rel in build.get("warm", []): | ||
| 261 | + src = os.path.normpath(os.path.join(context, rel)) | ||
| 262 | + dest = f"{self.workdir}/{rel}" | ||
| 263 | + if os.path.isdir(src): | ||
| 264 | + image = image.add_local_dir(src, dest, copy=True) | ||
| 265 | + else: | ||
| 266 | + image = image.add_local_file(src, dest, copy=True) | ||
| 267 | + if setup := build.get("setup", []): | ||
| 268 | + image = image.run_commands(*setup, volumes=self.volumes) | ||
| 229 | # `ignore` is what keeps a build tree out of the image. A checkout that | 269 | # `ignore` is what keeps a build tree out of the image. A checkout that |
| 230 | # has been built in locally carries its output -- flutter/build and the | 270 | # has been built in locally carries its output -- flutter/build and the |
| 231 | # caches beside it were 395MB of a 441MB repo -- and all of it would be | 271 | # caches beside it were 395MB of a 441MB repo -- and all of it would be |
| @@ -341,6 +381,8 @@ class Container: | |||
| 341 | kwargs["experimental_options"] = dict(opts) | 381 | kwargs["experimental_options"] = dict(opts) |
| 342 | if volumes := self.volumes: | 382 | if volumes := self.volumes: |
| 343 | kwargs["volumes"] = volumes | 383 | kwargs["volumes"] = volumes |
| 384 | + if self.ports: | ||
| 385 | + kwargs["encrypted_ports"] = list(self.ports) | ||
| 344 | return kwargs | 386 | return kwargs |
| 345 | 387 | ||
| 346 | def shell_command(self, override: str = "") -> str: | 388 | def shell_command(self, override: str = "") -> str: |
| @@ -376,6 +418,7 @@ class Container: | |||
| 376 | env={k: str(v) for k, v in self.env.items()}, | 418 | env={k: str(v) for k, v in self.env.items()}, |
| 377 | **self.sandbox_kwargs, | 419 | **self.sandbox_kwargs, |
| 378 | ) | 420 | ) |
| 421 | + self._print_tunnels(sb) | ||
| 379 | sb.wait() | 422 | sb.wait() |
| 380 | if sb.returncode != 0: | 423 | if sb.returncode != 0: |
| 381 | raise RuntimeError( | 424 | raise RuntimeError( |
| @@ -384,6 +427,19 @@ class Container: | |||
| 384 | ) | 427 | ) |
| 385 | return "" | 428 | return "" |
| 386 | 429 | ||
| 430 | + def _print_tunnels(self, sb: "modal.Sandbox") -> None: | ||
| 431 | + """Say where a tunnelled port can be reached, once the sandbox is up. | ||
| 432 | + | ||
| 433 | + `tunnels()` blocks until the Sandbox is scheduled, which is why this | ||
| 434 | + is called after create and not folded into it. Nothing prints when | ||
| 435 | + [network] ports is empty, which is every container but the serving | ||
| 436 | + ones. | ||
| 437 | + """ | ||
| 438 | + if not self.ports: | ||
| 439 | + return | ||
| 440 | + for port, tunnel in sb.tunnels().items(): | ||
| 441 | + print(f" :{port} -> {tunnel.url}") | ||
| 442 | + | ||
| 387 | def open_sandbox(self) -> "modal.Sandbox": | 443 | def open_sandbox(self) -> "modal.Sandbox": |
| 388 | """Start a Sandbox and leave it running, for `scripts/shell`. | 444 | """Start a Sandbox and leave it running, for `scripts/shell`. |
| 389 | 445 | ||
added
.modal/flutter-web/README.md +55 -0 | new file mode 100644 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +# `flutter-web` | |
| 2 | + | |
| 3 | + modal run .modal/flutter-web/container.py | |
| 4 | + just modal flutter-web | |
| 5 | + | |
| 6 | +Defined by `container.toml`; see `../spec.md` for the keys. | |
| 7 | +Built on the published `arch-nix` image. | |
| 8 | + | |
| 9 | +`flutter-dev` with the Linux target swapped for the web one: the same | |
| 10 | +`clojure -M:cljd compile` over the same `flutter/src` and `common/`, | |
| 11 | +then dart2js instead of CMake and Ninja. Same incremental shape -- | |
| 12 | +the working tree and Flutter's caches live on the `devshell` volume, | |
| 13 | +under `frq-flutter-web/` so the desktop container's directory beside | |
| 14 | +it is untouched. | |
| 15 | + | |
| 16 | +Runs as a Sandbox on a real VM (kernel 6.x, not gVisor). The command | |
| 17 | +is the sandbox's own process, so it dies when the command exits. | |
| 18 | + | |
| 19 | +To look at what it built, ask for the serve action -- `[network] | |
| 20 | +ports` tunnels 8080 out, and the URL is printed once the sandbox is | |
| 21 | +scheduled: | |
| 22 | + | |
| 23 | + modal run .modal/flutter-web/container.py \ | |
| 24 | + --command 'cd /devshell/frq-flutter-web && nix develop /app#flutter-web --command just -f /devshell/frq-flutter-web/justfile flutter-web serve' | |
| 25 | + | |
| 26 | +That blocks until you Ctrl-C it, and it bills until you do. | |
| 27 | + | |
| 28 | +## It compiles; it does not start | |
| 29 | + | |
| 30 | +dart2js links the whole app -- `main.dart.js` is 3.6MB and has | |
| 31 | +`frq`, `irc`, `atproto` and `handshake` all through it, so the | |
| 32 | +`dart:io` imports under `flutter/src` are not the wall they look | |
| 33 | +like. What stops it is the first line of `main`: | |
| 34 | +`getApplicationDocumentsDirectory` is a platform channel, path_provider | |
| 35 | +ships no web implementation, and the channel with no handler behind it | |
| 36 | +throws `MissingPluginException`. `main` awaits that before installing | |
| 37 | +`frq.io.dart`, so no widget is ever built and the page stays white. | |
| 38 | + | |
| 39 | +That is the seam doing its job rather than a build problem. The fix is | |
| 40 | +a `frq.io.web` behind `frq.io` -- the browser's answer for a private | |
| 41 | +file is IndexedDB or localStorage, not a directory -- and `main` | |
| 42 | +choosing it the way `flutter/src/frq/main.cljd` chooses the Dart one | |
| 43 | +now. `frq.net.web` is the next one after it, for the same reason: a | |
| 44 | +browser has no raw socket, so the IRC connection wants a WebSocket. | |
| 45 | + | |
| 46 | +## Serving it | |
| 47 | + | |
| 48 | +`serve.py` is the other half: a Function that mounts the same volume | |
| 49 | +and hands out `flutter/build/web`, so a rebuild in the Sandbox is | |
| 50 | +picked up by the next cold start with nothing redeployed. | |
| 51 | + | |
| 52 | + modal deploy .modal/flutter-web/serve.py | |
| 53 | + | |
| 54 | +`[network] ports` tunnels 8080 out of the Sandbox as well, for the | |
| 55 | +case where you want the build and the server to be one process. | |
| new file mode 100644 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +# `flutter-web` | ||
| 2 | + | ||
| 3 | + modal run .modal/flutter-web/container.py | ||
| 4 | + just modal flutter-web | ||
| 5 | + | ||
| 6 | +Defined by `container.toml`; see `../spec.md` for the keys. | ||
| 7 | +Built on the published `arch-nix` image. | ||
| 8 | + | ||
| 9 | +`flutter-dev` with the Linux target swapped for the web one: the same | ||
| 10 | +`clojure -M:cljd compile` over the same `flutter/src` and `common/`, | ||
| 11 | +then dart2js instead of CMake and Ninja. Same incremental shape -- | ||
| 12 | +the working tree and Flutter's caches live on the `devshell` volume, | ||
| 13 | +under `frq-flutter-web/` so the desktop container's directory beside | ||
| 14 | +it is untouched. | ||
| 15 | + | ||
| 16 | +Runs as a Sandbox on a real VM (kernel 6.x, not gVisor). The command | ||
| 17 | +is the sandbox's own process, so it dies when the command exits. | ||
| 18 | + | ||
| 19 | +To look at what it built, ask for the serve action -- `[network] | ||
| 20 | +ports` tunnels 8080 out, and the URL is printed once the sandbox is | ||
| 21 | +scheduled: | ||
| 22 | + | ||
| 23 | + modal run .modal/flutter-web/container.py \ | ||
| 24 | + --command 'cd /devshell/frq-flutter-web && nix develop /app#flutter-web --command just -f /devshell/frq-flutter-web/justfile flutter-web serve' | ||
| 25 | + | ||
| 26 | +That blocks until you Ctrl-C it, and it bills until you do. | ||
| 27 | + | ||
| 28 | +## It compiles; it does not start | ||
| 29 | + | ||
| 30 | +dart2js links the whole app -- `main.dart.js` is 3.6MB and has | ||
| 31 | +`frq`, `irc`, `atproto` and `handshake` all through it, so the | ||
| 32 | +`dart:io` imports under `flutter/src` are not the wall they look | ||
| 33 | +like. What stops it is the first line of `main`: | ||
| 34 | +`getApplicationDocumentsDirectory` is a platform channel, path_provider | ||
| 35 | +ships no web implementation, and the channel with no handler behind it | ||
| 36 | +throws `MissingPluginException`. `main` awaits that before installing | ||
| 37 | +`frq.io.dart`, so no widget is ever built and the page stays white. | ||
| 38 | + | ||
| 39 | +That is the seam doing its job rather than a build problem. The fix is | ||
| 40 | +a `frq.io.web` behind `frq.io` -- the browser's answer for a private | ||
| 41 | +file is IndexedDB or localStorage, not a directory -- and `main` | ||
| 42 | +choosing it the way `flutter/src/frq/main.cljd` chooses the Dart one | ||
| 43 | +now. `frq.net.web` is the next one after it, for the same reason: a | ||
| 44 | +browser has no raw socket, so the IRC connection wants a WebSocket. | ||
| 45 | + | ||
| 46 | +## Serving it | ||
| 47 | + | ||
| 48 | +`serve.py` is the other half: a Function that mounts the same volume | ||
| 49 | +and hands out `flutter/build/web`, so a rebuild in the Sandbox is | ||
| 50 | +picked up by the next cold start with nothing redeployed. | ||
| 51 | + | ||
| 52 | + modal deploy .modal/flutter-web/serve.py | ||
| 53 | + | ||
| 54 | +`[network] ports` tunnels 8080 out of the Sandbox as well, for the | ||
| 55 | +case where you want the build and the server to be one process. | ||
added
.modal/flutter-web/container.py +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +"""Generated stub -- the container is defined by container.toml. | |
| 2 | + | |
| 3 | +Edit container.toml, not this file. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import os | |
| 7 | +import sys | |
| 8 | + | |
| 9 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| 10 | + | |
| 11 | +from _loader import Container # noqa: E402 | |
| 12 | + | |
| 13 | +c = Container.from_toml(__file__) | |
| 14 | +image, app = c.image, c.app | |
| 15 | + | |
| 16 | + | |
| 17 | +# No @app.function here: a Sandbox runs its command as its own process and | |
| 18 | +# nothing of this module is imported into it. Registering a Function would be | |
| 19 | +# dead weight, and its kwargs are where vm_runtime would be wrongly applied. | |
| 20 | +# One entrypoint, not two: a second `@app.local_entrypoint` makes plain | |
| 21 | +# `modal run container.py` ambiguous, and Modal refuses it rather than | |
| 22 | +# picking. `--shell` is a flag on the one there is. | |
| 23 | +@app.local_entrypoint() | |
| 24 | +def main(command: str = "", shell: bool = False): | |
| 25 | + if not shell: | |
| 26 | + c.run_sandbox(command) | |
| 27 | + return | |
| 28 | + | |
| 29 | + # `modal shell --image` only takes registry references, so it cannot be | |
| 30 | + # pointed at a published Modal image like arch-nix. Attaching to a running | |
| 31 | + # Sandbox can, and that Sandbox is this container: same image, same | |
| 32 | + # volumes, same env. | |
| 33 | + sb = c.open_sandbox() | |
| 34 | + print(f"sandbox {sb.object_id} up, with {', '.join(c.volumes) or 'no volumes'}") | |
| 35 | + print(f" attach: modal shell {sb.object_id} (from another terminal)") | |
| 36 | + print("Ctrl-C here takes it down.") | |
| 37 | + # Blocks on `sleep infinity`, which is the point: an ephemeral app stops | |
| 38 | + # when its local entrypoint returns, and stopping the app terminates the | |
| 39 | + # Sandbox with it -- so returning here would leave nothing to attach to. | |
| 40 | + try: | |
| 41 | + sb.wait() | |
| 42 | + except KeyboardInterrupt: | |
| 43 | + print("terminating") | |
| 44 | + sb.terminate() | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +"""Generated stub -- the container is defined by container.toml. | ||
| 2 | + | ||
| 3 | +Edit container.toml, not this file. | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import os | ||
| 7 | +import sys | ||
| 8 | + | ||
| 9 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | ||
| 10 | + | ||
| 11 | +from _loader import Container # noqa: E402 | ||
| 12 | + | ||
| 13 | +c = Container.from_toml(__file__) | ||
| 14 | +image, app = c.image, c.app | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +# No @app.function here: a Sandbox runs its command as its own process and | ||
| 18 | +# nothing of this module is imported into it. Registering a Function would be | ||
| 19 | +# dead weight, and its kwargs are where vm_runtime would be wrongly applied. | ||
| 20 | +# One entrypoint, not two: a second `@app.local_entrypoint` makes plain | ||
| 21 | +# `modal run container.py` ambiguous, and Modal refuses it rather than | ||
| 22 | +# picking. `--shell` is a flag on the one there is. | ||
| 23 | +@app.local_entrypoint() | ||
| 24 | +def main(command: str = "", shell: bool = False): | ||
| 25 | + if not shell: | ||
| 26 | + c.run_sandbox(command) | ||
| 27 | + return | ||
| 28 | + | ||
| 29 | + # `modal shell --image` only takes registry references, so it cannot be | ||
| 30 | + # pointed at a published Modal image like arch-nix. Attaching to a running | ||
| 31 | + # Sandbox can, and that Sandbox is this container: same image, same | ||
| 32 | + # volumes, same env. | ||
| 33 | + sb = c.open_sandbox() | ||
| 34 | + print(f"sandbox {sb.object_id} up, with {', '.join(c.volumes) or 'no volumes'}") | ||
| 35 | + print(f" attach: modal shell {sb.object_id} (from another terminal)") | ||
| 36 | + print("Ctrl-C here takes it down.") | ||
| 37 | + # Blocks on `sleep infinity`, which is the point: an ephemeral app stops | ||
| 38 | + # when its local entrypoint returns, and stopping the app terminates the | ||
| 39 | + # Sandbox with it -- so returning here would leave nothing to attach to. | ||
| 40 | + try: | ||
| 41 | + sb.wait() | ||
| 42 | + except KeyboardInterrupt: | ||
| 43 | + print("terminating") | ||
| 44 | + sb.terminate() | ||
added
.modal/flutter-web/container.toml +233 -0 | new file mode 100644 | ||
| @@ -0,0 +1,233 @@ | ||
| 1 | +[container] | |
| 2 | +name = "frq-flutter-web" | |
| 3 | +description = "the Flutter web build, incremental, in a nix devShell" | |
| 4 | +base = "arch-nix" | |
| 5 | +# A Sandbox, not a Function: it runs on a real VM, the command is the | |
| 6 | +# sandbox's own process so it dies when the command does, and only a Sandbox | |
| 7 | +# can hold open a tunnel -- which is the whole of `serve`. | |
| 8 | +runtime = "sandbox" | |
| 9 | + | |
| 10 | +[build] | |
| 11 | +# The container lives inside the repo it builds, so the copy is rooted two | |
| 12 | +# levels up and `.` is the whole tree. | |
| 13 | +context = "../.." | |
| 14 | +include = ["."] | |
| 15 | +# The devShell, baked in rather than entered -- `flutter-dev`'s trick and for | |
| 16 | +# its reasons. `print-dev-env` writes the whole environment out as shell and | |
| 17 | +# realises its inputs on the way, so the closure becomes an image layer | |
| 18 | +# instead of a fetch every container pays for; sourcing it from .bashrc means | |
| 19 | +# a shell attached to this container *is* the devShell. | |
| 20 | +# | |
| 21 | +# `dev` stays for the case where the baked env is stale against a flake edit. | |
| 22 | +# The toolchain layer, and what it is allowed to depend on. | |
| 23 | +# | |
| 24 | +# `warm` is copied before `setup` runs and is deliberately six files: the | |
| 25 | +# flake and its lock, plus the four `cljd-deps` actually reads -- it does | |
| 26 | +# `cp ${./common/deps.edn}`, `${./flutter/deps.edn}`, `${./flutter/pubspec.yaml}` | |
| 27 | +# and `${./flutter/pubspec.lock}` and nothing else. That is the whole of what | |
| 28 | +# `nix develop .#flutter-web` needs to evaluate, so this layer moves when a | |
| 29 | +# dependency moves and not when a line of ClojureDart does. | |
| 30 | +# | |
| 31 | +# The point of the split: `[build] commands` run after the full source copy, | |
| 32 | +# so editing `flutter/src/frq/net/web.cljd` used to invalidate them and spend | |
| 33 | +# minutes re-warming a devShell that had not changed. Here the image is built | |
| 34 | +# from the toolchain and the program is built in the sandbox, which is where | |
| 35 | +# it was always going to happen anyway. | |
| 36 | +warm = [ | |
| 37 | + "flake.nix", "flake.lock", | |
| 38 | + "common/deps.edn", | |
| 39 | + "flutter/deps.edn", "flutter/pubspec.yaml", "flutter/pubspec.lock", | |
| 40 | +] | |
| 41 | +# The devShell, baked in rather than entered. `print-dev-env` writes the whole | |
| 42 | +# environment out as shell and realises its inputs on the way, so the closure | |
| 43 | +# becomes an image layer instead of a fetch every container pays for; sourcing | |
| 44 | +# it from .bashrc means a shell attached to this container *is* the devShell. | |
| 45 | +# | |
| 46 | +# Best-effort, and the reason is gVisor. An image build is a Function | |
| 47 | +# underneath, so it cannot *build* a derivation -- and `print-dev-env` realises | |
| 48 | +# the shell's inputs, which here includes `flutter-wrapped-…-sdk-links.drv`, | |
| 49 | +# in no binary cache and therefore built. Under gVisor that dies with | |
| 50 | +# `unexpected EOF reading a line`, and the ptyshim that papered over it is | |
| 51 | +# deprecated. Nothing here is load-bearing: [run] enters `nix develop` itself, | |
| 52 | +# on the VM, where a real pty makes the same build work. | |
| 53 | +setup = [ | |
| 54 | + "nix print-dev-env /app#flutter-web --accept-flake-config --extra-substituters file:///nix-cache > /etc/devshell.sh || rm -f /etc/devshell.sh", | |
| 55 | + "echo '[ -s /etc/devshell.sh ] && . /etc/devshell.sh' >> /root/.bashrc", | |
| 56 | + "printf '#!/bin/sh\\nexec nix develop /app#flutter-web \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev", | |
| 57 | +] | |
| 58 | +# The build state a local checkout carries, wanted by nothing out here: this | |
| 59 | +# container builds into a volume of its own, and the jolt and clojure caches | |
| 60 | +# are the laptop's. | |
| 61 | +ignore = [ | |
| 62 | + "flutter/build", "flutter/.home", "flutter/.dart_tool", | |
| 63 | + "flutter/.clojuredart", "flutter/.cpcache", | |
| 64 | + ".jolt", ".cpcache", "result", "build", ".git", | |
| 65 | +] | |
| 66 | + | |
| 67 | +# `nix-cache` is the binary cache every container here reads from and writes | |
| 68 | +# back to. `devshell` is the working state of a `nix develop` loop, shared by | |
| 69 | +# every container that has one -- each gets its own directory under it, named | |
| 70 | +# for the devShell it belongs to, so `flutter-web` and `flutter-desktop` never | |
| 71 | +# write the same tree even though they share a `.home`-shaped cache layout. | |
| 72 | +# Modal Volumes have no locking, so those directory names are the only thing | |
| 73 | +# keeping them apart, and two runs of the *same* devshell must not overlap. | |
| 74 | +[volumes] | |
| 75 | +nix-cache = "/nix-cache" | |
| 76 | +devshell = "/devshell" | |
| 77 | + | |
| 78 | +[resources] | |
| 79 | +cpu = 8 | |
| 80 | +memory = 16384 | |
| 81 | +timeout = 3600 | |
| 82 | + | |
| 83 | +# The port `just flutter-web serve` listens on, tunnelled out. Nothing is | |
| 84 | +# served unless the command asks for it -- a plain build exits and the tunnel | |
| 85 | +# closes with the sandbox -- but the port has to be declared at create time, | |
| 86 | +# so it is declared once here and `modal run --command` decides whether | |
| 87 | +# anything ever binds it. | |
| 88 | +[network] | |
| 89 | +ports = [8080] | |
| 90 | + | |
| 91 | +[run] | |
| 92 | +workdir = "/app" | |
| 93 | +# Nix for the dependencies, the ordinary toolchain for the build -- the | |
| 94 | +# `flutter-dev` argument, unchanged: a derivation is all-or-nothing, so any | |
| 95 | +# edit under `nix build` is a fresh sandbox and a fresh compile of everything. | |
| 96 | +# Here the devShell supplies dart2js and the engine artifacts and `flutter | |
| 97 | +# build web` decides what is stale. | |
| 98 | +command = """ | |
| 99 | +set -e | |
| 100 | +# This container's own directory on the shared devshell volume, named for the | |
| 101 | +# devShell whose state it keeps. `flutter-desktop` has its own beside it. | |
| 102 | +SHELL_DIR=/devshell/frq-flutter-web | |
| 103 | +mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache" | |
| 104 | + | |
| 105 | +# A worktree's `.git` is a *file* naming a gitdir back on the machine that | |
| 106 | +# copied it in, and nix believes it and goes looking for a path that is not | |
| 107 | +# here. It has to go before any flake reference to /app. | |
| 108 | +rm -rf /app/.git | |
| 109 | + | |
| 110 | +echo "sync: /app -> $SHELL_DIR" | |
| 111 | +# `nix shell --command` and not `nix profile install`: a profile install puts | |
| 112 | +# rsync in ~/.nix-profile/bin, which is not on the PATH of the shell already | |
| 113 | +# running. | |
| 114 | +# | |
| 115 | +# rsync and not cp, with --checksum and not mtimes: Modal copies the source in | |
| 116 | +# with fresh timestamps every run, so a plain copy looks entirely new to | |
| 117 | +# Flutter and rebuilds the lot. --checksum compares content and leaves the | |
| 118 | +# unchanged files' timestamps alone, which is the whole basis of the | |
| 119 | +# incremental build. | |
| 120 | +# | |
| 121 | +# The excludes are the state we are here to keep -- overwriting them from /app | |
| 122 | +# would defeat the volume. `flutter/web/` is NOT on the list: it is committed | |
| 123 | +# now, because the OAuth client keeps a script there, so it has to arrive from | |
| 124 | +# /app like any other source. | |
| 125 | +nix shell nixpkgs#rsync --accept-flake-config \ | |
| 126 | + --extra-substituters file:///nix-cache --command \ | |
| 127 | + rsync -a --checksum --delete \ | |
| 128 | + --exclude 'flutter/.home/' \ | |
| 129 | + --exclude 'flutter/.clojuredart/' \ | |
| 130 | + --exclude 'flutter/build/' \ | |
| 131 | + --exclude 'flutter/.dart_tool/' \ | |
| 132 | + --exclude '.git' \ | |
| 133 | + /app/ "$SHELL_DIR/" | |
| 134 | + | |
| 135 | +cd "$SHELL_DIR" | |
| 136 | +echo "state carried over:" | |
| 137 | +du -sh flutter/.home flutter/.clojuredart flutter/build 2>/dev/null \ | |
| 138 | + || echo " (none yet -- first run)" | |
| 139 | + | |
| 140 | +# Evaluated from /app and built in the volume, as `flutter-dev` does and for | |
| 141 | +# the same two reasons: /app is the pristine copy, so nix stores a source tree | |
| 142 | +# of the repo rather than one carrying gigabytes of flutter/build, while the | |
| 143 | +# recipe still runs where the state it reuses lives. `just -f` is what puts it | |
| 144 | +# there, since the recipe cds to its own justfile's directory. | |
| 145 | +# The baked devShell if there is one, and `nix develop` if there is not. | |
| 146 | +# | |
| 147 | +# This is the difference between a two-minute rebuild and a three-minute one. | |
| 148 | +# `nix develop /app#flutter-web` re-copies the whole repo into the nix store | |
| 149 | +# and re-evaluates the flake on every run -- the flake's source is the tree, | |
| 150 | +# so any edit makes it a new source -- and all of that to arrive at an | |
| 151 | +# environment the image already computed with `print-dev-env` and wrote to | |
| 152 | +# /etc/devshell.sh. Sourcing it is the same PATH and the same variables with | |
| 153 | +# no evaluation at all. | |
| 154 | +# | |
| 155 | +# The fallback is not decoration: that setup step is best-effort, because | |
| 156 | +# realising the devShell under gVisor can fail (see [build] setup), and a | |
| 157 | +# container whose bake did not happen still has to build. | |
| 158 | +# The devShell environment, computed once and kept on the volume. | |
| 159 | +# | |
| 160 | +# `nix develop /app#flutter-web` re-copies the whole repo into the nix store | |
| 161 | +# and re-evaluates the flake on every run -- the flake's source IS the tree, | |
| 162 | +# so any edit makes it a new source -- to arrive at an environment that has | |
| 163 | +# not changed. `print-dev-env` writes that environment out as shell, and | |
| 164 | +# sourcing it is the same PATH and the same variables with no evaluation. | |
| 165 | +# | |
| 166 | +# Here and not in [build] setup, which is where it used to be: an image build | |
| 167 | +# is a Function under gVisor, where nix cannot realise a derivation, and this | |
| 168 | +# devShell's closure contains one no cache can answer for | |
| 169 | +# (flutter-wrapped-...-sdk-links). That bake failed every time and was made | |
| 170 | +# non-fatal, which meant it silently never happened. On the VM it works, and | |
| 171 | +# the volume is what makes it worth doing once. | |
| 172 | +# | |
| 173 | +# Regenerated when flake.lock's CONTENT changes -- by hash and never by mtime. | |
| 174 | +# Modal copies /app in with fresh timestamps on every run, so `-nt` says the | |
| 175 | +# lock is newer every single time and the cache never hits. That is the same | |
| 176 | +# trap the rsync above documents and works around with --checksum; it catches | |
| 177 | +# anything here that asks a file when it changed. | |
| 178 | +# Beside the working tree and NOT inside it: the rsync above runs with | |
| 179 | +# --delete, so anything under $SHELL_DIR that is not in /app is removed on | |
| 180 | +# every run. Kept in there, this cache was deleted moments before it was | |
| 181 | +# consulted, which is why it recomputed every time while claiming to be a | |
| 182 | +# cache. | |
| 183 | +ENV_DIR="$SHELL_DIR.env" | |
| 184 | +mkdir -p "$ENV_DIR" | |
| 185 | +ENV_SH="$ENV_DIR/devshell.sh" | |
| 186 | +STAMP="$ENV_DIR/devshell.lock" | |
| 187 | +WANT="$(sha256sum /app/flake.lock | cut -d' ' -f1)" | |
| 188 | +if [ ! -s "$ENV_SH" ] || [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then | |
| 189 | + echo "devshell: computing (first run, or flake.lock changed)" | |
| 190 | + nix print-dev-env /app#flutter-web --accept-flake-config \ | |
| 191 | + --extra-substituters file:///nix-cache > "$ENV_SH.tmp" | |
| 192 | + mv "$ENV_SH.tmp" "$ENV_SH" | |
| 193 | + echo "$WANT" > "$STAMP" | |
| 194 | +else | |
| 195 | + echo "devshell: reusing the computed env" | |
| 196 | +fi | |
| 197 | + | |
| 198 | +# A subshell, so what the env sets does not leak into the `nix copy` below -- | |
| 199 | +# that wants the container's own nix, not the shell's. `set +u` because a | |
| 200 | +# printed dev env references variables that need not be set. | |
| 201 | +( set +u; . "$ENV_SH"; set -u | |
| 202 | + just -f "$SHELL_DIR/justfile" flutter-web ) | |
| 203 | + | |
| 204 | +echo "built:" | |
| 205 | +du -sh flutter/build/web | |
| 206 | + | |
| 207 | +# The devShell's closure is gigabytes of Flutter and Dart, and the store it | |
| 208 | +# landed in belongs to the image rather than to a volume -- so without this | |
| 209 | +# every run re-fetches it from upstream. Written back, the next run | |
| 210 | +# substitutes it from file:///nix-cache instead. | |
| 211 | +if [ -f /nix-cache/nix-cache-info ]; then | |
| 212 | + echo "cache: writing the devShell closure back" | |
| 213 | + nix copy --no-check-sigs --all --to file:///nix-cache | |
| 214 | +fi | |
| 215 | +""" | |
| 216 | +# Nix's own cache, on the volume rather than in the container. Without it | |
| 217 | +# every Sandbox starts empty and `nix develop` re-clones the flake's git | |
| 218 | +# inputs, because flake.lock pins which revision to fetch and not whether it | |
| 219 | +# is already on disk. Set here rather than in the command so an interactive | |
| 220 | +# shell into this container gets it too. | |
| 221 | +env = { XDG_CACHE_HOME = "/devshell/frq-flutter-web/.cache" } | |
| 222 | + | |
| 223 | +[nix] | |
| 224 | +# Every nix command in the container reads the mounted cache, including one | |
| 225 | +# typed by hand in a shell. | |
| 226 | +substituters = ["file:///nix-cache"] | |
| 227 | +# No devShell warming at image build time: this enters `nix develop` at run | |
| 228 | +# time, on the VM, where the cache answers for its closure. The ptyshim that | |
| 229 | +# warming would need under gVisor is deprecated and does not come back. | |
| 230 | +flake = false | |
| 231 | +shim = false | |
| 232 | + | |
| 233 | +# [experimental] overrides the sandbox default of vm_runtime = true. | |
| new file mode 100644 | |||
| @@ -0,0 +1,233 @@ | |||
| 1 | +[container] | ||
| 2 | +name = "frq-flutter-web" | ||
| 3 | +description = "the Flutter web build, incremental, in a nix devShell" | ||
| 4 | +base = "arch-nix" | ||
| 5 | +# A Sandbox, not a Function: it runs on a real VM, the command is the | ||
| 6 | +# sandbox's own process so it dies when the command does, and only a Sandbox | ||
| 7 | +# can hold open a tunnel -- which is the whole of `serve`. | ||
| 8 | +runtime = "sandbox" | ||
| 9 | + | ||
| 10 | +[build] | ||
| 11 | +# The container lives inside the repo it builds, so the copy is rooted two | ||
| 12 | +# levels up and `.` is the whole tree. | ||
| 13 | +context = "../.." | ||
| 14 | +include = ["."] | ||
| 15 | +# The devShell, baked in rather than entered -- `flutter-dev`'s trick and for | ||
| 16 | +# its reasons. `print-dev-env` writes the whole environment out as shell and | ||
| 17 | +# realises its inputs on the way, so the closure becomes an image layer | ||
| 18 | +# instead of a fetch every container pays for; sourcing it from .bashrc means | ||
| 19 | +# a shell attached to this container *is* the devShell. | ||
| 20 | +# | ||
| 21 | +# `dev` stays for the case where the baked env is stale against a flake edit. | ||
| 22 | +# The toolchain layer, and what it is allowed to depend on. | ||
| 23 | +# | ||
| 24 | +# `warm` is copied before `setup` runs and is deliberately six files: the | ||
| 25 | +# flake and its lock, plus the four `cljd-deps` actually reads -- it does | ||
| 26 | +# `cp ${./common/deps.edn}`, `${./flutter/deps.edn}`, `${./flutter/pubspec.yaml}` | ||
| 27 | +# and `${./flutter/pubspec.lock}` and nothing else. That is the whole of what | ||
| 28 | +# `nix develop .#flutter-web` needs to evaluate, so this layer moves when a | ||
| 29 | +# dependency moves and not when a line of ClojureDart does. | ||
| 30 | +# | ||
| 31 | +# The point of the split: `[build] commands` run after the full source copy, | ||
| 32 | +# so editing `flutter/src/frq/net/web.cljd` used to invalidate them and spend | ||
| 33 | +# minutes re-warming a devShell that had not changed. Here the image is built | ||
| 34 | +# from the toolchain and the program is built in the sandbox, which is where | ||
| 35 | +# it was always going to happen anyway. | ||
| 36 | +warm = [ | ||
| 37 | + "flake.nix", "flake.lock", | ||
| 38 | + "common/deps.edn", | ||
| 39 | + "flutter/deps.edn", "flutter/pubspec.yaml", "flutter/pubspec.lock", | ||
| 40 | +] | ||
| 41 | +# The devShell, baked in rather than entered. `print-dev-env` writes the whole | ||
| 42 | +# environment out as shell and realises its inputs on the way, so the closure | ||
| 43 | +# becomes an image layer instead of a fetch every container pays for; sourcing | ||
| 44 | +# it from .bashrc means a shell attached to this container *is* the devShell. | ||
| 45 | +# | ||
| 46 | +# Best-effort, and the reason is gVisor. An image build is a Function | ||
| 47 | +# underneath, so it cannot *build* a derivation -- and `print-dev-env` realises | ||
| 48 | +# the shell's inputs, which here includes `flutter-wrapped-…-sdk-links.drv`, | ||
| 49 | +# in no binary cache and therefore built. Under gVisor that dies with | ||
| 50 | +# `unexpected EOF reading a line`, and the ptyshim that papered over it is | ||
| 51 | +# deprecated. Nothing here is load-bearing: [run] enters `nix develop` itself, | ||
| 52 | +# on the VM, where a real pty makes the same build work. | ||
| 53 | +setup = [ | ||
| 54 | + "nix print-dev-env /app#flutter-web --accept-flake-config --extra-substituters file:///nix-cache > /etc/devshell.sh || rm -f /etc/devshell.sh", | ||
| 55 | + "echo '[ -s /etc/devshell.sh ] && . /etc/devshell.sh' >> /root/.bashrc", | ||
| 56 | + "printf '#!/bin/sh\\nexec nix develop /app#flutter-web \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev", | ||
| 57 | +] | ||
| 58 | +# The build state a local checkout carries, wanted by nothing out here: this | ||
| 59 | +# container builds into a volume of its own, and the jolt and clojure caches | ||
| 60 | +# are the laptop's. | ||
| 61 | +ignore = [ | ||
| 62 | + "flutter/build", "flutter/.home", "flutter/.dart_tool", | ||
| 63 | + "flutter/.clojuredart", "flutter/.cpcache", | ||
| 64 | + ".jolt", ".cpcache", "result", "build", ".git", | ||
| 65 | +] | ||
| 66 | + | ||
| 67 | +# `nix-cache` is the binary cache every container here reads from and writes | ||
| 68 | +# back to. `devshell` is the working state of a `nix develop` loop, shared by | ||
| 69 | +# every container that has one -- each gets its own directory under it, named | ||
| 70 | +# for the devShell it belongs to, so `flutter-web` and `flutter-desktop` never | ||
| 71 | +# write the same tree even though they share a `.home`-shaped cache layout. | ||
| 72 | +# Modal Volumes have no locking, so those directory names are the only thing | ||
| 73 | +# keeping them apart, and two runs of the *same* devshell must not overlap. | ||
| 74 | +[volumes] | ||
| 75 | +nix-cache = "/nix-cache" | ||
| 76 | +devshell = "/devshell" | ||
| 77 | + | ||
| 78 | +[resources] | ||
| 79 | +cpu = 8 | ||
| 80 | +memory = 16384 | ||
| 81 | +timeout = 3600 | ||
| 82 | + | ||
| 83 | +# The port `just flutter-web serve` listens on, tunnelled out. Nothing is | ||
| 84 | +# served unless the command asks for it -- a plain build exits and the tunnel | ||
| 85 | +# closes with the sandbox -- but the port has to be declared at create time, | ||
| 86 | +# so it is declared once here and `modal run --command` decides whether | ||
| 87 | +# anything ever binds it. | ||
| 88 | +[network] | ||
| 89 | +ports = [8080] | ||
| 90 | + | ||
| 91 | +[run] | ||
| 92 | +workdir = "/app" | ||
| 93 | +# Nix for the dependencies, the ordinary toolchain for the build -- the | ||
| 94 | +# `flutter-dev` argument, unchanged: a derivation is all-or-nothing, so any | ||
| 95 | +# edit under `nix build` is a fresh sandbox and a fresh compile of everything. | ||
| 96 | +# Here the devShell supplies dart2js and the engine artifacts and `flutter | ||
| 97 | +# build web` decides what is stale. | ||
| 98 | +command = """ | ||
| 99 | +set -e | ||
| 100 | +# This container's own directory on the shared devshell volume, named for the | ||
| 101 | +# devShell whose state it keeps. `flutter-desktop` has its own beside it. | ||
| 102 | +SHELL_DIR=/devshell/frq-flutter-web | ||
| 103 | +mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache" | ||
| 104 | + | ||
| 105 | +# A worktree's `.git` is a *file* naming a gitdir back on the machine that | ||
| 106 | +# copied it in, and nix believes it and goes looking for a path that is not | ||
| 107 | +# here. It has to go before any flake reference to /app. | ||
| 108 | +rm -rf /app/.git | ||
| 109 | + | ||
| 110 | +echo "sync: /app -> $SHELL_DIR" | ||
| 111 | +# `nix shell --command` and not `nix profile install`: a profile install puts | ||
| 112 | +# rsync in ~/.nix-profile/bin, which is not on the PATH of the shell already | ||
| 113 | +# running. | ||
| 114 | +# | ||
| 115 | +# rsync and not cp, with --checksum and not mtimes: Modal copies the source in | ||
| 116 | +# with fresh timestamps every run, so a plain copy looks entirely new to | ||
| 117 | +# Flutter and rebuilds the lot. --checksum compares content and leaves the | ||
| 118 | +# unchanged files' timestamps alone, which is the whole basis of the | ||
| 119 | +# incremental build. | ||
| 120 | +# | ||
| 121 | +# The excludes are the state we are here to keep -- overwriting them from /app | ||
| 122 | +# would defeat the volume. `flutter/web/` is NOT on the list: it is committed | ||
| 123 | +# now, because the OAuth client keeps a script there, so it has to arrive from | ||
| 124 | +# /app like any other source. | ||
| 125 | +nix shell nixpkgs#rsync --accept-flake-config \ | ||
| 126 | + --extra-substituters file:///nix-cache --command \ | ||
| 127 | + rsync -a --checksum --delete \ | ||
| 128 | + --exclude 'flutter/.home/' \ | ||
| 129 | + --exclude 'flutter/.clojuredart/' \ | ||
| 130 | + --exclude 'flutter/build/' \ | ||
| 131 | + --exclude 'flutter/.dart_tool/' \ | ||
| 132 | + --exclude '.git' \ | ||
| 133 | + /app/ "$SHELL_DIR/" | ||
| 134 | + | ||
| 135 | +cd "$SHELL_DIR" | ||
| 136 | +echo "state carried over:" | ||
| 137 | +du -sh flutter/.home flutter/.clojuredart flutter/build 2>/dev/null \ | ||
| 138 | + || echo " (none yet -- first run)" | ||
| 139 | + | ||
| 140 | +# Evaluated from /app and built in the volume, as `flutter-dev` does and for | ||
| 141 | +# the same two reasons: /app is the pristine copy, so nix stores a source tree | ||
| 142 | +# of the repo rather than one carrying gigabytes of flutter/build, while the | ||
| 143 | +# recipe still runs where the state it reuses lives. `just -f` is what puts it | ||
| 144 | +# there, since the recipe cds to its own justfile's directory. | ||
| 145 | +# The baked devShell if there is one, and `nix develop` if there is not. | ||
| 146 | +# | ||
| 147 | +# This is the difference between a two-minute rebuild and a three-minute one. | ||
| 148 | +# `nix develop /app#flutter-web` re-copies the whole repo into the nix store | ||
| 149 | +# and re-evaluates the flake on every run -- the flake's source is the tree, | ||
| 150 | +# so any edit makes it a new source -- and all of that to arrive at an | ||
| 151 | +# environment the image already computed with `print-dev-env` and wrote to | ||
| 152 | +# /etc/devshell.sh. Sourcing it is the same PATH and the same variables with | ||
| 153 | +# no evaluation at all. | ||
| 154 | +# | ||
| 155 | +# The fallback is not decoration: that setup step is best-effort, because | ||
| 156 | +# realising the devShell under gVisor can fail (see [build] setup), and a | ||
| 157 | +# container whose bake did not happen still has to build. | ||
| 158 | +# The devShell environment, computed once and kept on the volume. | ||
| 159 | +# | ||
| 160 | +# `nix develop /app#flutter-web` re-copies the whole repo into the nix store | ||
| 161 | +# and re-evaluates the flake on every run -- the flake's source IS the tree, | ||
| 162 | +# so any edit makes it a new source -- to arrive at an environment that has | ||
| 163 | +# not changed. `print-dev-env` writes that environment out as shell, and | ||
| 164 | +# sourcing it is the same PATH and the same variables with no evaluation. | ||
| 165 | +# | ||
| 166 | +# Here and not in [build] setup, which is where it used to be: an image build | ||
| 167 | +# is a Function under gVisor, where nix cannot realise a derivation, and this | ||
| 168 | +# devShell's closure contains one no cache can answer for | ||
| 169 | +# (flutter-wrapped-...-sdk-links). That bake failed every time and was made | ||
| 170 | +# non-fatal, which meant it silently never happened. On the VM it works, and | ||
| 171 | +# the volume is what makes it worth doing once. | ||
| 172 | +# | ||
| 173 | +# Regenerated when flake.lock's CONTENT changes -- by hash and never by mtime. | ||
| 174 | +# Modal copies /app in with fresh timestamps on every run, so `-nt` says the | ||
| 175 | +# lock is newer every single time and the cache never hits. That is the same | ||
| 176 | +# trap the rsync above documents and works around with --checksum; it catches | ||
| 177 | +# anything here that asks a file when it changed. | ||
| 178 | +# Beside the working tree and NOT inside it: the rsync above runs with | ||
| 179 | +# --delete, so anything under $SHELL_DIR that is not in /app is removed on | ||
| 180 | +# every run. Kept in there, this cache was deleted moments before it was | ||
| 181 | +# consulted, which is why it recomputed every time while claiming to be a | ||
| 182 | +# cache. | ||
| 183 | +ENV_DIR="$SHELL_DIR.env" | ||
| 184 | +mkdir -p "$ENV_DIR" | ||
| 185 | +ENV_SH="$ENV_DIR/devshell.sh" | ||
| 186 | +STAMP="$ENV_DIR/devshell.lock" | ||
| 187 | +WANT="$(sha256sum /app/flake.lock | cut -d' ' -f1)" | ||
| 188 | +if [ ! -s "$ENV_SH" ] || [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then | ||
| 189 | + echo "devshell: computing (first run, or flake.lock changed)" | ||
| 190 | + nix print-dev-env /app#flutter-web --accept-flake-config \ | ||
| 191 | + --extra-substituters file:///nix-cache > "$ENV_SH.tmp" | ||
| 192 | + mv "$ENV_SH.tmp" "$ENV_SH" | ||
| 193 | + echo "$WANT" > "$STAMP" | ||
| 194 | +else | ||
| 195 | + echo "devshell: reusing the computed env" | ||
| 196 | +fi | ||
| 197 | + | ||
| 198 | +# A subshell, so what the env sets does not leak into the `nix copy` below -- | ||
| 199 | +# that wants the container's own nix, not the shell's. `set +u` because a | ||
| 200 | +# printed dev env references variables that need not be set. | ||
| 201 | +( set +u; . "$ENV_SH"; set -u | ||
| 202 | + just -f "$SHELL_DIR/justfile" flutter-web ) | ||
| 203 | + | ||
| 204 | +echo "built:" | ||
| 205 | +du -sh flutter/build/web | ||
| 206 | + | ||
| 207 | +# The devShell's closure is gigabytes of Flutter and Dart, and the store it | ||
| 208 | +# landed in belongs to the image rather than to a volume -- so without this | ||
| 209 | +# every run re-fetches it from upstream. Written back, the next run | ||
| 210 | +# substitutes it from file:///nix-cache instead. | ||
| 211 | +if [ -f /nix-cache/nix-cache-info ]; then | ||
| 212 | + echo "cache: writing the devShell closure back" | ||
| 213 | + nix copy --no-check-sigs --all --to file:///nix-cache | ||
| 214 | +fi | ||
| 215 | +""" | ||
| 216 | +# Nix's own cache, on the volume rather than in the container. Without it | ||
| 217 | +# every Sandbox starts empty and `nix develop` re-clones the flake's git | ||
| 218 | +# inputs, because flake.lock pins which revision to fetch and not whether it | ||
| 219 | +# is already on disk. Set here rather than in the command so an interactive | ||
| 220 | +# shell into this container gets it too. | ||
| 221 | +env = { XDG_CACHE_HOME = "/devshell/frq-flutter-web/.cache" } | ||
| 222 | + | ||
| 223 | +[nix] | ||
| 224 | +# Every nix command in the container reads the mounted cache, including one | ||
| 225 | +# typed by hand in a shell. | ||
| 226 | +substituters = ["file:///nix-cache"] | ||
| 227 | +# No devShell warming at image build time: this enters `nix develop` at run | ||
| 228 | +# time, on the VM, where the cache answers for its closure. The ptyshim that | ||
| 229 | +# warming would need under gVisor is deprecated and does not come back. | ||
| 230 | +flake = false | ||
| 231 | +shim = false | ||
| 232 | + | ||
| 233 | +# [experimental] overrides the sandbox default of vm_runtime = true. | ||
added
.modal/flutter-web/serve.py +119 -0 | new file mode 100644 | ||
| @@ -0,0 +1,119 @@ | ||
| 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 | + ) | |
| new file mode 100644 | |||
| @@ -0,0 +1,119 @@ | |||
| 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 | + ) | ||
modified
flake.nix +39 -0 | @@ -1041,6 +1041,45 @@ | ||
| 1041 | 1041 | # by hand. |
| 1042 | 1042 | FRQ_FLUTTER_DESKTOP = "1"; |
| 1043 | 1043 | }; |
| 1044 | + | |
| 1045 | + # The third frontend, and the first that is not a window: the same | |
| 1046 | + # ClojureDart half again, over Flutter's web target. `flutter build | |
| 1047 | + # web` compiles the generated Dart with dart2js and writes a | |
| 1048 | + # directory of HTML, JS and assets rather than an executable. | |
| 1049 | + # | |
| 1050 | + # The thinnest of the three shells, because the web target is the | |
| 1051 | + # one that needs no host toolchain at all: no JDK and no SDK as the | |
| 1052 | + # APK wants, no GTK and no C++ as the desktop one does, and no nixGL | |
| 1053 | + # — the GL is the browser's problem and the browser is not ours. | |
| 1054 | + # mkShellNoCC says so: nothing here compiles C. | |
| 1055 | + # | |
| 1056 | + # python3 is not a build input. It is `just flutter-web serve`: the | |
| 1057 | + # output is a directory of static files and something has to hand it | |
| 1058 | + # over HTTP, and the alternative — `flutter run -d web-server` — | |
| 1059 | + # rebuilds rather than serving what was built, which is the wrong | |
| 1060 | + # half of the loop when the build already happened in a container. | |
| 1061 | + flutter-web = pkgs.mkShellNoCC { | |
| 1062 | + name = "frq-flutter-web"; | |
| 1063 | + | |
| 1064 | + packages = [ | |
| 1065 | + pkgs.clojure | |
| 1066 | + pkgs.flutter | |
| 1067 | + pkgs.just | |
| 1068 | + pkgs.git | |
| 1069 | + pkgs.python3 | |
| 1070 | + ]; | |
| 1071 | + | |
| 1072 | + # The `flutter` shell's caches, and deliberately the same ones for | |
| 1073 | + # the same reason `flutter-desktop` shares them: all three targets | |
| 1074 | + # are one `clojure -M:cljd compile` over one deps.edn, and a third | |
| 1075 | + # set of caches would be a third answer to what it resolved | |
| 1076 | + # against. | |
| 1077 | + FRQ_CLJD_DEPS = "${self.packages.${pkgs.stdenv.hostPlatform.system}.cljd-deps}"; | |
| 1078 | + | |
| 1079 | + # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the | |
| 1080 | + # desktop one's. | |
| 1081 | + FRQ_FLUTTER_WEB = "1"; | |
| 1082 | + }; | |
| 1044 | 1083 | }); |
| 1045 | 1084 | |
| 1046 | 1085 | apps = forEachSystem (pkgs: { |
| @@ -1041,6 +1041,45 @@ | |||
| 1041 | # by hand. | 1041 | # by hand. |
| 1042 | FRQ_FLUTTER_DESKTOP = "1"; | 1042 | FRQ_FLUTTER_DESKTOP = "1"; |
| 1043 | }; | 1043 | }; |
| 1044 | + | ||
| 1045 | + # The third frontend, and the first that is not a window: the same | ||
| 1046 | + # ClojureDart half again, over Flutter's web target. `flutter build | ||
| 1047 | + # web` compiles the generated Dart with dart2js and writes a | ||
| 1048 | + # directory of HTML, JS and assets rather than an executable. | ||
| 1049 | + # | ||
| 1050 | + # The thinnest of the three shells, because the web target is the | ||
| 1051 | + # one that needs no host toolchain at all: no JDK and no SDK as the | ||
| 1052 | + # APK wants, no GTK and no C++ as the desktop one does, and no nixGL | ||
| 1053 | + # — the GL is the browser's problem and the browser is not ours. | ||
| 1054 | + # mkShellNoCC says so: nothing here compiles C. | ||
| 1055 | + # | ||
| 1056 | + # python3 is not a build input. It is `just flutter-web serve`: the | ||
| 1057 | + # output is a directory of static files and something has to hand it | ||
| 1058 | + # over HTTP, and the alternative — `flutter run -d web-server` — | ||
| 1059 | + # rebuilds rather than serving what was built, which is the wrong | ||
| 1060 | + # half of the loop when the build already happened in a container. | ||
| 1061 | + flutter-web = pkgs.mkShellNoCC { | ||
| 1062 | + name = "frq-flutter-web"; | ||
| 1063 | + | ||
| 1064 | + packages = [ | ||
| 1065 | + pkgs.clojure | ||
| 1066 | + pkgs.flutter | ||
| 1067 | + pkgs.just | ||
| 1068 | + pkgs.git | ||
| 1069 | + pkgs.python3 | ||
| 1070 | + ]; | ||
| 1071 | + | ||
| 1072 | + # The `flutter` shell's caches, and deliberately the same ones for | ||
| 1073 | + # the same reason `flutter-desktop` shares them: all three targets | ||
| 1074 | + # are one `clojure -M:cljd compile` over one deps.edn, and a third | ||
| 1075 | + # set of caches would be a third answer to what it resolved | ||
| 1076 | + # against. | ||
| 1077 | + FRQ_CLJD_DEPS = "${self.packages.${pkgs.stdenv.hostPlatform.system}.cljd-deps}"; | ||
| 1078 | + | ||
| 1079 | + # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the | ||
| 1080 | + # desktop one's. | ||
| 1081 | + FRQ_FLUTTER_WEB = "1"; | ||
| 1082 | + }; | ||
| 1044 | }); | 1083 | }); |
| 1045 | 1084 | ||
| 1046 | apps = forEachSystem (pkgs: { | 1085 | apps = forEachSystem (pkgs: { |
added
flutter/lib/main_web.dart +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +// `frq.main-web`, not `frq.main_web`: cljd's ns-to-lib maps "." to "/" and | |
| 2 | +// leaves every other character alone, so the hyphen in the namespace survives | |
| 3 | +// into the filename. This file keeps the underscore because `-t` names it on | |
| 4 | +// a command line. | |
| 5 | +export "cljd-out/frq/main-web.dart" show main; | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +// `frq.main-web`, not `frq.main_web`: cljd's ns-to-lib maps "." to "/" and | ||
| 2 | +// leaves every other character alone, so the hyphen in the namespace survives | ||
| 3 | +// into the filename. This file keeps the underscore because `-t` names it on | ||
| 4 | +// a command line. | ||
| 5 | +export "cljd-out/frq/main-web.dart" show main; | ||
added
flutter/src/frq/io/web.cljd +220 -0 | new file mode 100644 | ||
| @@ -0,0 +1,220 @@ | ||
| 1 | +(ns frq.io.web | |
| 2 | + "The browser's answers to `frq.io`, over dart:html. | |
| 3 | + | |
| 4 | + The third backend, beside `frq.io.jolt` on the desktop and `frq.io.dart` | |
| 5 | + under Flutter's Android and Linux targets — and the one that shows what the | |
| 6 | + seam was for. Everything the other two get from a filesystem, this one does | |
| 7 | + not have: a browser has no directory, no path, no mode bits and no uid to | |
| 8 | + keep a file from. What it has is an origin and a key-value store that | |
| 9 | + belongs to it. | |
| 10 | + | |
| 11 | + So the paths `frq.store` and `frq.oauth` hand over are not opened here, they | |
| 12 | + are *spelled*: `config-dir` answers with a name that is a prefix and nothing | |
| 13 | + else, and every file under it is one localStorage key. Nothing in `common/` | |
| 14 | + can tell the difference, because nothing in `common/` was ever allowed to | |
| 15 | + ask for more than `slurp`, `spit` and `list-dir`. | |
| 16 | + | |
| 17 | + Compiled only for the web, and only because nothing else requires it: | |
| 18 | + `clojure -M:cljd compile` walks out from one entry namespace, so this file | |
| 19 | + reaches the build through `frq.main-web` and is absent from the APK and the | |
| 20 | + Linux bundle — which is what keeps `dart:html` out of two targets that have | |
| 21 | + no such library. | |
| 22 | + | |
| 23 | + dart:html rather than package:web and dart:js_interop: it is in the SDK, so | |
| 24 | + it adds nothing to pubspec.yaml, and what is wanted here is localStorage and | |
| 25 | + XMLHttpRequest rather than anything that needs the newer binding. It is | |
| 26 | + deprecated, and the day it goes this file is where the change lands." | |
| 27 | + (:require ["dart:convert" :as conv] | |
| 28 | + ["dart:html" :as html] | |
| 29 | + [frq.io :as fio])) | |
| 30 | + | |
| 31 | +(defonce ^:private uptime | |
| 32 | + ;; Dart has no monotonic clock as such; a Stopwatch started at load is one. | |
| 33 | + ;; The same trick `frq.io.dart` plays, and portable because Stopwatch is in | |
| 34 | + ;; the core library rather than in dart:io. | |
| 35 | + (doto (Stopwatch.) (.start))) | |
| 36 | + | |
| 37 | +(def ^:private root | |
| 38 | + "What `config-dir` answers. A prefix rather than a directory: there is no | |
| 39 | + such place, and the only thing the callers do with it is join a filename | |
| 40 | + onto it. Leading slash so a path built from it reads like the ones the other | |
| 41 | + two backends hand out, which matters only when one ends up in a log." | |
| 42 | + "/frq") | |
| 43 | + | |
| 44 | +(defn- store | |
| 45 | + "localStorage, which is a `Map<String,String>` scoped to this origin. | |
| 46 | + | |
| 47 | + Fetched per call rather than held in a `defonce`: a browser with site data | |
| 48 | + blocked throws on the *access*, not at load, and a backend that died while | |
| 49 | + being installed would take the whole app with it. Here the throw is caught | |
| 50 | + by whichever operation asked, and that operation alone answers nil." | |
| 51 | + ^html/Storage [] | |
| 52 | + (.-localStorage html/window)) | |
| 53 | + | |
| 54 | +;; Files are keys, and the prefix is what makes them findable: `list-dir` has | |
| 55 | +;; nothing but the key space to search, so a file has to carry its own path. | |
| 56 | +(def ^:private file-prefix "frq:file:") | |
| 57 | + | |
| 58 | +(defn- k [path] (str file-prefix path)) | |
| 59 | + | |
| 60 | +;; Storage is a Dart Map, and a Dart Map is read with `get` and written with | |
| 61 | +;; the `[]=` operator — never with `aget`/`aset`, which compile to | |
| 62 | +;; `(x as List)[k as int]` and throw a TypeError on anything that is not a | |
| 63 | +;; List. Every catch below duly swallowed it, so localStorage quietly did | |
| 64 | +;; nothing at all: reads answered nil, writes answered false, and the app | |
| 65 | +;; looked like it simply had no saved state. | |
| 66 | +;; | |
| 67 | +;; `Object` and not `Exception` throughout this file: a browser throws Errors | |
| 68 | +;; where dart:io throws Exceptions -- `UnsupportedError` for a library that is | |
| 69 | +;; not there, and a DOM failure need not be an Exception either. In Dart an | |
| 70 | +;; Error is not an Exception, so `on Exception catch` lets it straight through, | |
| 71 | +;; and every catch below exists precisely so that the operation answers instead | |
| 72 | +;; of taking its caller down. | |
| 73 | +(defn- slurp* [path] | |
| 74 | + (try | |
| 75 | + (let [^html/Storage s (store) key (k path)] | |
| 76 | + (when (.containsKey s key) (get s key))) | |
| 77 | + (catch Object _ nil))) | |
| 78 | + | |
| 79 | +(defn- spit* [path s] | |
| 80 | + (try | |
| 81 | + (. ^html/Storage (store) "[]=" (k path) (str s)) | |
| 82 | + true | |
| 83 | + (catch Object _ false))) | |
| 84 | + | |
| 85 | +(defn- file-keys | |
| 86 | + "Every stored path, as paths rather than as keys." | |
| 87 | + [] | |
| 88 | + (try | |
| 89 | + (into [] (comp (map str) | |
| 90 | + (filter (fn [^String key] (.startsWith key file-prefix))) | |
| 91 | + (map (fn [^String key] (str (.substring key (count file-prefix)))))) | |
| 92 | + (.-keys ^html/Storage (store))) | |
| 93 | + (catch Object _ []))) | |
| 94 | + | |
| 95 | +(defn- list-dir* | |
| 96 | + "The paths directly under `path`. | |
| 97 | + | |
| 98 | + Directly, not recursively: `frq.store` reads a directory expecting the files | |
| 99 | + in it, and a nested key handed back whole would be opened as a file and read | |
| 100 | + as nothing. So a key that goes deeper contributes its first segment, once — | |
| 101 | + which is the directory the other two backends would have shown." | |
| 102 | + [path] | |
| 103 | + (let [^String pre (str path (when-not (.endsWith (str path) "/") "/"))] | |
| 104 | + (into [] | |
| 105 | + (comp (filter (fn [^String p] (.startsWith p pre))) | |
| 106 | + (map (fn [^String p] | |
| 107 | + (let [^String rest (str (.substring p (count pre))) | |
| 108 | + cut (.indexOf rest "/")] | |
| 109 | + (if (neg? cut) p (str pre (.substring rest 0 cut)))))) | |
| 110 | + (distinct)) | |
| 111 | + (file-keys)))) | |
| 112 | + | |
| 113 | +(defn- fetch-text! | |
| 114 | + "`frq.io/fetch-text!` over XMLHttpRequest. | |
| 115 | + | |
| 116 | + Built up by hand rather than through `HttpRequest.request`, which wants its | |
| 117 | + headers as a Dart `Map<String,String>` — and a ClojureDart map is not one. | |
| 118 | + `setRequestHeader` per pair says the same thing with no conversion, and the | |
| 119 | + callbacks are already the shape the seam asks for, so there is no Future to | |
| 120 | + bridge either. | |
| 121 | + | |
| 122 | + No user-agent: the browser sets it and refuses to be told otherwise, where | |
| 123 | + `frq.io.dart` sends `frq`. That is the one header the web half cannot honour. | |
| 124 | + | |
| 125 | + What it cannot do anything about is the origin. Every request from here is | |
| 126 | + cross-origin and subject to CORS, so a server that does not send the headers | |
| 127 | + is a request that fails before it is made, no matter what this sends." | |
| 128 | + [url headers on-done] | |
| 129 | + (try | |
| 130 | + (let [req (html/HttpRequest.)] | |
| 131 | + (.open req "GET" (str url)) | |
| 132 | + (doseq [[hk hv] headers] | |
| 133 | + (try (.setRequestHeader req (str hk) (str hv)) | |
| 134 | + ;; A header the browser reserves throws rather than being ignored, | |
| 135 | + ;; and one refused header should not lose the whole request. | |
| 136 | + (catch Object _ nil))) | |
| 137 | + (.addEventListener req "load" | |
| 138 | + (fn [_] | |
| 139 | + (on-done (when (= 200 (.-status req)) (.-responseText req))) | |
| 140 | + nil)) | |
| 141 | + (.addEventListener req "error" (fn [_] (on-done nil) nil)) | |
| 142 | + (.addEventListener req "abort" (fn [_] (on-done nil) nil)) | |
| 143 | + (.send req)) | |
| 144 | + (catch Object _ (on-done nil))) | |
| 145 | + nil) | |
| 146 | + | |
| 147 | +(defn- utf8-string | |
| 148 | + "Byte values back to the text they spell. | |
| 149 | + | |
| 150 | + Typed `List<int>` and not a cljd vector, for `frq.io.dart`'s reason: Dart's | |
| 151 | + `utf8.decode` wants a real one, and a PersistentVector is not." | |
| 152 | + [bs] | |
| 153 | + (let [n (count bs) | |
| 154 | + ^#/(List int) ary (.filled List n 0)] | |
| 155 | + (loop [s (seq bs) i 0] | |
| 156 | + (if (nil? s) | |
| 157 | + (.decode conv/utf8 ary) | |
| 158 | + (do (aset ary i (first s)) | |
| 159 | + (recur (next s) (inc i))))))) | |
| 160 | + | |
| 161 | +(defn install! | |
| 162 | + "Install the browser's answers. No argument and nothing to await, unlike | |
| 163 | + `frq.io.dart/install!` — which is the whole reason the web entry point is a | |
| 164 | + separate one: there is no storage directory to wait for, so `frq.main-web` | |
| 165 | + can install before its first frame rather than after a Future." | |
| 166 | + [] | |
| 167 | + (fio/install! | |
| 168 | + {;; A browser has no environment. nil rather than a throw: every caller of | |
| 169 | + ;; `getenv` in `common/` already treats a missing variable as ordinary — | |
| 170 | + ;; it is how the desktop says a thing was not configured. | |
| 171 | + :getenv (fn [_] nil) | |
| 172 | + :fetch-text! fetch-text! | |
| 173 | + ;; `_blank`, and the reader's own browser is already the browser: the | |
| 174 | + ;; OAuth handoff that `frq.io.dart` sends to an external application is | |
| 175 | + ;; simply another tab here. Popup blockers can refuse it, which is why the | |
| 176 | + ;; seam lets this answer false — the OAuth screen shows the URL to open by | |
| 177 | + ;; hand. | |
| 178 | + :open-url! (fn [url] | |
| 179 | + (try | |
| 180 | + (some? (.open html/window (str url) "_blank")) | |
| 181 | + (catch Object _ false))) | |
| 182 | + :config-dir (fn [] root) | |
| 183 | + :file-exists? (fn [p] (some? (slurp* p))) | |
| 184 | + ;; A directory exists here exactly when something is under it. There is no | |
| 185 | + ;; empty directory to find, because `mkdirs!` writes nothing. | |
| 186 | + :directory? (fn [p] (boolean (seq (list-dir* p)))) | |
| 187 | + :list-dir list-dir* | |
| 188 | + ;; Nothing to make. A key holds its whole path, so the parent of a file is | |
| 189 | + ;; created by writing the file, and asking for it in advance is a no-op | |
| 190 | + ;; that has to answer truthfully anyway. | |
| 191 | + :mkdirs! (fn [_] true) | |
| 192 | + :delete-file! (fn [p] | |
| 193 | + (try (.remove ^html/Storage (store) (k p)) true | |
| 194 | + (catch Object _ false))) | |
| 195 | + :slurp slurp* | |
| 196 | + :spit spit* | |
| 197 | + ;; localStorage is already origin-private: no other site can read it, and | |
| 198 | + ;; there is no second uid on this machine that reaches it through the | |
| 199 | + ;; browser. So the private write is the ordinary write, the way it is on | |
| 200 | + ;; Android — and unlike Android, what it is private *from* is worth saying | |
| 201 | + ;; plainly: not from anyone with the reader's disk, only from other pages. | |
| 202 | + :write-private-file! spit* | |
| 203 | + ;; Not answerable, and nil is the seam's word for that. A save here would | |
| 204 | + ;; mean an anchor with a download attribute over a blob, and what the | |
| 205 | + ;; caller has is a path into a media cache that is itself a localStorage | |
| 206 | + ;; key holding text — so there is nothing to make a blob from until | |
| 207 | + ;; `frq.media` has a web half. The caller shows the answer, and the answer | |
| 208 | + ;; is that it did not land anywhere. | |
| 209 | + :save-to-downloads! (fn [_ _] nil) | |
| 210 | + :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s)))) | |
| 211 | + :utf8-string utf8-string | |
| 212 | + :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) | |
| 213 | + :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed ^Stopwatch uptime)))) | |
| 214 | + ;; Dart carries the zone in the runtime on the web too -- it comes from the | |
| 215 | + ;; browser rather than from /etc/localtime, which is the case `frq.clock` | |
| 216 | + ;; was already written not to care about. | |
| 217 | + :local-offset-seconds (fn [secs] | |
| 218 | + (-> (DateTime/fromMillisecondsSinceEpoch (* 1000 secs)) | |
| 219 | + (.-timeZoneOffset) | |
| 220 | + (.-inSeconds)))})) | |
| new file mode 100644 | |||
| @@ -0,0 +1,220 @@ | |||
| 1 | +(ns frq.io.web | ||
| 2 | + "The browser's answers to `frq.io`, over dart:html. | ||
| 3 | + | ||
| 4 | + The third backend, beside `frq.io.jolt` on the desktop and `frq.io.dart` | ||
| 5 | + under Flutter's Android and Linux targets — and the one that shows what the | ||
| 6 | + seam was for. Everything the other two get from a filesystem, this one does | ||
| 7 | + not have: a browser has no directory, no path, no mode bits and no uid to | ||
| 8 | + keep a file from. What it has is an origin and a key-value store that | ||
| 9 | + belongs to it. | ||
| 10 | + | ||
| 11 | + So the paths `frq.store` and `frq.oauth` hand over are not opened here, they | ||
| 12 | + are *spelled*: `config-dir` answers with a name that is a prefix and nothing | ||
| 13 | + else, and every file under it is one localStorage key. Nothing in `common/` | ||
| 14 | + can tell the difference, because nothing in `common/` was ever allowed to | ||
| 15 | + ask for more than `slurp`, `spit` and `list-dir`. | ||
| 16 | + | ||
| 17 | + Compiled only for the web, and only because nothing else requires it: | ||
| 18 | + `clojure -M:cljd compile` walks out from one entry namespace, so this file | ||
| 19 | + reaches the build through `frq.main-web` and is absent from the APK and the | ||
| 20 | + Linux bundle — which is what keeps `dart:html` out of two targets that have | ||
| 21 | + no such library. | ||
| 22 | + | ||
| 23 | + dart:html rather than package:web and dart:js_interop: it is in the SDK, so | ||
| 24 | + it adds nothing to pubspec.yaml, and what is wanted here is localStorage and | ||
| 25 | + XMLHttpRequest rather than anything that needs the newer binding. It is | ||
| 26 | + deprecated, and the day it goes this file is where the change lands." | ||
| 27 | + (:require ["dart:convert" :as conv] | ||
| 28 | + ["dart:html" :as html] | ||
| 29 | + [frq.io :as fio])) | ||
| 30 | + | ||
| 31 | +(defonce ^:private uptime | ||
| 32 | + ;; Dart has no monotonic clock as such; a Stopwatch started at load is one. | ||
| 33 | + ;; The same trick `frq.io.dart` plays, and portable because Stopwatch is in | ||
| 34 | + ;; the core library rather than in dart:io. | ||
| 35 | + (doto (Stopwatch.) (.start))) | ||
| 36 | + | ||
| 37 | +(def ^:private root | ||
| 38 | + "What `config-dir` answers. A prefix rather than a directory: there is no | ||
| 39 | + such place, and the only thing the callers do with it is join a filename | ||
| 40 | + onto it. Leading slash so a path built from it reads like the ones the other | ||
| 41 | + two backends hand out, which matters only when one ends up in a log." | ||
| 42 | + "/frq") | ||
| 43 | + | ||
| 44 | +(defn- store | ||
| 45 | + "localStorage, which is a `Map<String,String>` scoped to this origin. | ||
| 46 | + | ||
| 47 | + Fetched per call rather than held in a `defonce`: a browser with site data | ||
| 48 | + blocked throws on the *access*, not at load, and a backend that died while | ||
| 49 | + being installed would take the whole app with it. Here the throw is caught | ||
| 50 | + by whichever operation asked, and that operation alone answers nil." | ||
| 51 | + ^html/Storage [] | ||
| 52 | + (.-localStorage html/window)) | ||
| 53 | + | ||
| 54 | +;; Files are keys, and the prefix is what makes them findable: `list-dir` has | ||
| 55 | +;; nothing but the key space to search, so a file has to carry its own path. | ||
| 56 | +(def ^:private file-prefix "frq:file:") | ||
| 57 | + | ||
| 58 | +(defn- k [path] (str file-prefix path)) | ||
| 59 | + | ||
| 60 | +;; Storage is a Dart Map, and a Dart Map is read with `get` and written with | ||
| 61 | +;; the `[]=` operator — never with `aget`/`aset`, which compile to | ||
| 62 | +;; `(x as List)[k as int]` and throw a TypeError on anything that is not a | ||
| 63 | +;; List. Every catch below duly swallowed it, so localStorage quietly did | ||
| 64 | +;; nothing at all: reads answered nil, writes answered false, and the app | ||
| 65 | +;; looked like it simply had no saved state. | ||
| 66 | +;; | ||
| 67 | +;; `Object` and not `Exception` throughout this file: a browser throws Errors | ||
| 68 | +;; where dart:io throws Exceptions -- `UnsupportedError` for a library that is | ||
| 69 | +;; not there, and a DOM failure need not be an Exception either. In Dart an | ||
| 70 | +;; Error is not an Exception, so `on Exception catch` lets it straight through, | ||
| 71 | +;; and every catch below exists precisely so that the operation answers instead | ||
| 72 | +;; of taking its caller down. | ||
| 73 | +(defn- slurp* [path] | ||
| 74 | + (try | ||
| 75 | + (let [^html/Storage s (store) key (k path)] | ||
| 76 | + (when (.containsKey s key) (get s key))) | ||
| 77 | + (catch Object _ nil))) | ||
| 78 | + | ||
| 79 | +(defn- spit* [path s] | ||
| 80 | + (try | ||
| 81 | + (. ^html/Storage (store) "[]=" (k path) (str s)) | ||
| 82 | + true | ||
| 83 | + (catch Object _ false))) | ||
| 84 | + | ||
| 85 | +(defn- file-keys | ||
| 86 | + "Every stored path, as paths rather than as keys." | ||
| 87 | + [] | ||
| 88 | + (try | ||
| 89 | + (into [] (comp (map str) | ||
| 90 | + (filter (fn [^String key] (.startsWith key file-prefix))) | ||
| 91 | + (map (fn [^String key] (str (.substring key (count file-prefix)))))) | ||
| 92 | + (.-keys ^html/Storage (store))) | ||
| 93 | + (catch Object _ []))) | ||
| 94 | + | ||
| 95 | +(defn- list-dir* | ||
| 96 | + "The paths directly under `path`. | ||
| 97 | + | ||
| 98 | + Directly, not recursively: `frq.store` reads a directory expecting the files | ||
| 99 | + in it, and a nested key handed back whole would be opened as a file and read | ||
| 100 | + as nothing. So a key that goes deeper contributes its first segment, once — | ||
| 101 | + which is the directory the other two backends would have shown." | ||
| 102 | + [path] | ||
| 103 | + (let [^String pre (str path (when-not (.endsWith (str path) "/") "/"))] | ||
| 104 | + (into [] | ||
| 105 | + (comp (filter (fn [^String p] (.startsWith p pre))) | ||
| 106 | + (map (fn [^String p] | ||
| 107 | + (let [^String rest (str (.substring p (count pre))) | ||
| 108 | + cut (.indexOf rest "/")] | ||
| 109 | + (if (neg? cut) p (str pre (.substring rest 0 cut)))))) | ||
| 110 | + (distinct)) | ||
| 111 | + (file-keys)))) | ||
| 112 | + | ||
| 113 | +(defn- fetch-text! | ||
| 114 | + "`frq.io/fetch-text!` over XMLHttpRequest. | ||
| 115 | + | ||
| 116 | + Built up by hand rather than through `HttpRequest.request`, which wants its | ||
| 117 | + headers as a Dart `Map<String,String>` — and a ClojureDart map is not one. | ||
| 118 | + `setRequestHeader` per pair says the same thing with no conversion, and the | ||
| 119 | + callbacks are already the shape the seam asks for, so there is no Future to | ||
| 120 | + bridge either. | ||
| 121 | + | ||
| 122 | + No user-agent: the browser sets it and refuses to be told otherwise, where | ||
| 123 | + `frq.io.dart` sends `frq`. That is the one header the web half cannot honour. | ||
| 124 | + | ||
| 125 | + What it cannot do anything about is the origin. Every request from here is | ||
| 126 | + cross-origin and subject to CORS, so a server that does not send the headers | ||
| 127 | + is a request that fails before it is made, no matter what this sends." | ||
| 128 | + [url headers on-done] | ||
| 129 | + (try | ||
| 130 | + (let [req (html/HttpRequest.)] | ||
| 131 | + (.open req "GET" (str url)) | ||
| 132 | + (doseq [[hk hv] headers] | ||
| 133 | + (try (.setRequestHeader req (str hk) (str hv)) | ||
| 134 | + ;; A header the browser reserves throws rather than being ignored, | ||
| 135 | + ;; and one refused header should not lose the whole request. | ||
| 136 | + (catch Object _ nil))) | ||
| 137 | + (.addEventListener req "load" | ||
| 138 | + (fn [_] | ||
| 139 | + (on-done (when (= 200 (.-status req)) (.-responseText req))) | ||
| 140 | + nil)) | ||
| 141 | + (.addEventListener req "error" (fn [_] (on-done nil) nil)) | ||
| 142 | + (.addEventListener req "abort" (fn [_] (on-done nil) nil)) | ||
| 143 | + (.send req)) | ||
| 144 | + (catch Object _ (on-done nil))) | ||
| 145 | + nil) | ||
| 146 | + | ||
| 147 | +(defn- utf8-string | ||
| 148 | + "Byte values back to the text they spell. | ||
| 149 | + | ||
| 150 | + Typed `List<int>` and not a cljd vector, for `frq.io.dart`'s reason: Dart's | ||
| 151 | + `utf8.decode` wants a real one, and a PersistentVector is not." | ||
| 152 | + [bs] | ||
| 153 | + (let [n (count bs) | ||
| 154 | + ^#/(List int) ary (.filled List n 0)] | ||
| 155 | + (loop [s (seq bs) i 0] | ||
| 156 | + (if (nil? s) | ||
| 157 | + (.decode conv/utf8 ary) | ||
| 158 | + (do (aset ary i (first s)) | ||
| 159 | + (recur (next s) (inc i))))))) | ||
| 160 | + | ||
| 161 | +(defn install! | ||
| 162 | + "Install the browser's answers. No argument and nothing to await, unlike | ||
| 163 | + `frq.io.dart/install!` — which is the whole reason the web entry point is a | ||
| 164 | + separate one: there is no storage directory to wait for, so `frq.main-web` | ||
| 165 | + can install before its first frame rather than after a Future." | ||
| 166 | + [] | ||
| 167 | + (fio/install! | ||
| 168 | + {;; A browser has no environment. nil rather than a throw: every caller of | ||
| 169 | + ;; `getenv` in `common/` already treats a missing variable as ordinary — | ||
| 170 | + ;; it is how the desktop says a thing was not configured. | ||
| 171 | + :getenv (fn [_] nil) | ||
| 172 | + :fetch-text! fetch-text! | ||
| 173 | + ;; `_blank`, and the reader's own browser is already the browser: the | ||
| 174 | + ;; OAuth handoff that `frq.io.dart` sends to an external application is | ||
| 175 | + ;; simply another tab here. Popup blockers can refuse it, which is why the | ||
| 176 | + ;; seam lets this answer false — the OAuth screen shows the URL to open by | ||
| 177 | + ;; hand. | ||
| 178 | + :open-url! (fn [url] | ||
| 179 | + (try | ||
| 180 | + (some? (.open html/window (str url) "_blank")) | ||
| 181 | + (catch Object _ false))) | ||
| 182 | + :config-dir (fn [] root) | ||
| 183 | + :file-exists? (fn [p] (some? (slurp* p))) | ||
| 184 | + ;; A directory exists here exactly when something is under it. There is no | ||
| 185 | + ;; empty directory to find, because `mkdirs!` writes nothing. | ||
| 186 | + :directory? (fn [p] (boolean (seq (list-dir* p)))) | ||
| 187 | + :list-dir list-dir* | ||
| 188 | + ;; Nothing to make. A key holds its whole path, so the parent of a file is | ||
| 189 | + ;; created by writing the file, and asking for it in advance is a no-op | ||
| 190 | + ;; that has to answer truthfully anyway. | ||
| 191 | + :mkdirs! (fn [_] true) | ||
| 192 | + :delete-file! (fn [p] | ||
| 193 | + (try (.remove ^html/Storage (store) (k p)) true | ||
| 194 | + (catch Object _ false))) | ||
| 195 | + :slurp slurp* | ||
| 196 | + :spit spit* | ||
| 197 | + ;; localStorage is already origin-private: no other site can read it, and | ||
| 198 | + ;; there is no second uid on this machine that reaches it through the | ||
| 199 | + ;; browser. So the private write is the ordinary write, the way it is on | ||
| 200 | + ;; Android — and unlike Android, what it is private *from* is worth saying | ||
| 201 | + ;; plainly: not from anyone with the reader's disk, only from other pages. | ||
| 202 | + :write-private-file! spit* | ||
| 203 | + ;; Not answerable, and nil is the seam's word for that. A save here would | ||
| 204 | + ;; mean an anchor with a download attribute over a blob, and what the | ||
| 205 | + ;; caller has is a path into a media cache that is itself a localStorage | ||
| 206 | + ;; key holding text — so there is nothing to make a blob from until | ||
| 207 | + ;; `frq.media` has a web half. The caller shows the answer, and the answer | ||
| 208 | + ;; is that it did not land anywhere. | ||
| 209 | + :save-to-downloads! (fn [_ _] nil) | ||
| 210 | + :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s)))) | ||
| 211 | + :utf8-string utf8-string | ||
| 212 | + :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) | ||
| 213 | + :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed ^Stopwatch uptime)))) | ||
| 214 | + ;; Dart carries the zone in the runtime on the web too -- it comes from the | ||
| 215 | + ;; browser rather than from /etc/localtime, which is the case `frq.clock` | ||
| 216 | + ;; was already written not to care about. | ||
| 217 | + :local-offset-seconds (fn [secs] | ||
| 218 | + (-> (DateTime/fromMillisecondsSinceEpoch (* 1000 secs)) | ||
| 219 | + (.-timeZoneOffset) | ||
| 220 | + (.-inSeconds)))})) | ||
added
flutter/src/frq/main_web.cljd +52 -0 | new file mode 100644 | ||
| @@ -0,0 +1,52 @@ | ||
| 1 | +(ns frq.main-web | |
| 2 | + "The web entry point: `frq.main` with the browser's host instead of Dart's. | |
| 3 | + | |
| 4 | + A second entry namespace rather than a branch inside `frq.main`, and the | |
| 5 | + reason is the compiler rather than the design. `clojure -M:cljd compile` | |
| 6 | + walks out from one namespace, so what `frq.main` requires is what every | |
| 7 | + target compiles — and `frq.io.web` names `dart:html`, which the Android and | |
| 8 | + Linux targets have no library for. Requiring it from `frq.main` would break | |
| 9 | + the two builds that work in order to fix the one that does not. | |
| 10 | + | |
| 11 | + So the split is at the top: `frq.main/bind!` and `frq.main/start!` are the | |
| 12 | + shared halves, `frq.main/main` installs `frq.io.dart` after awaiting | |
| 13 | + path_provider, and this installs `frq.io.web` after awaiting nothing. | |
| 14 | + | |
| 15 | + That await is the bug this namespace exists to remove. path_provider ships | |
| 16 | + no web implementation, so `getApplicationSupportDirectory` reaches a | |
| 17 | + platform channel with no handler behind it and throws MissingPluginException | |
| 18 | + — before `host/install!`, before any widget, which is why the page went | |
| 19 | + white with a 3.6MB bundle that had loaded perfectly well. | |
| 20 | + | |
| 21 | + Built as `flutter build web -t lib/main_web.dart`; see `just flutter-web`. | |
| 22 | + | |
| 23 | + Sign-in here is this client's own OAuth rather than freeq's broker — see | |
| 24 | + `frq.oauth.web` for why the broker cannot answer a build served from an | |
| 25 | + origin it does not know." | |
| 26 | + (:require [frq.main :as app] | |
| 27 | + [frq.io.web :as host] | |
| 28 | + [frq.net.web :as net-web] | |
| 29 | + [frq.oauth.web :as oauth-web] | |
| 30 | + [frq.atproto.web :as atproto-web])) | |
| 31 | + | |
| 32 | +(defn ^:async main [] | |
| 33 | + (app/bind!) | |
| 34 | + (host/install!) | |
| 35 | + ;; The WebSocket transport, where `frq.main` installs the dart:io one. Both | |
| 36 | + ;; go in before `start!`, which asserts only on `frq.io` but connects | |
| 37 | + ;; through `frq.net` the moment a saved session restores. | |
| 38 | + (net-web/install!) | |
| 39 | + (oauth-web/install!) | |
| 40 | + ;; The app-password path: it reaches the reader's own PDS directly. Like | |
| 41 | + ;; the OAuth one above it, and unlike freeq's broker, nothing in it cares | |
| 42 | + ;; which origin this build is served from. | |
| 43 | + (atproto-web/install!) | |
| 44 | + ;; Two questions with one answer: did we just come back from the | |
| 45 | + ;; authorization server, and failing that was there a sign-in before today. | |
| 46 | + ;; Either way what lands is a session in `frq.cells`, which is what | |
| 47 | + ;; `frq.main/sign-in!` finds instead of starting the browser leg again. | |
| 48 | + (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))] | |
| 49 | + (await (app/start!)) | |
| 50 | + ;; A sign-in that has just completed should not then wait to be told to | |
| 51 | + ;; connect: the reader asked for this before they left for Bluesky. | |
| 52 | + (when signed-in? (app/connect!)))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,52 @@ | |||
| 1 | +(ns frq.main-web | ||
| 2 | + "The web entry point: `frq.main` with the browser's host instead of Dart's. | ||
| 3 | + | ||
| 4 | + A second entry namespace rather than a branch inside `frq.main`, and the | ||
| 5 | + reason is the compiler rather than the design. `clojure -M:cljd compile` | ||
| 6 | + walks out from one namespace, so what `frq.main` requires is what every | ||
| 7 | + target compiles — and `frq.io.web` names `dart:html`, which the Android and | ||
| 8 | + Linux targets have no library for. Requiring it from `frq.main` would break | ||
| 9 | + the two builds that work in order to fix the one that does not. | ||
| 10 | + | ||
| 11 | + So the split is at the top: `frq.main/bind!` and `frq.main/start!` are the | ||
| 12 | + shared halves, `frq.main/main` installs `frq.io.dart` after awaiting | ||
| 13 | + path_provider, and this installs `frq.io.web` after awaiting nothing. | ||
| 14 | + | ||
| 15 | + That await is the bug this namespace exists to remove. path_provider ships | ||
| 16 | + no web implementation, so `getApplicationSupportDirectory` reaches a | ||
| 17 | + platform channel with no handler behind it and throws MissingPluginException | ||
| 18 | + — before `host/install!`, before any widget, which is why the page went | ||
| 19 | + white with a 3.6MB bundle that had loaded perfectly well. | ||
| 20 | + | ||
| 21 | + Built as `flutter build web -t lib/main_web.dart`; see `just flutter-web`. | ||
| 22 | + | ||
| 23 | + Sign-in here is this client's own OAuth rather than freeq's broker — see | ||
| 24 | + `frq.oauth.web` for why the broker cannot answer a build served from an | ||
| 25 | + origin it does not know." | ||
| 26 | + (:require [frq.main :as app] | ||
| 27 | + [frq.io.web :as host] | ||
| 28 | + [frq.net.web :as net-web] | ||
| 29 | + [frq.oauth.web :as oauth-web] | ||
| 30 | + [frq.atproto.web :as atproto-web])) | ||
| 31 | + | ||
| 32 | +(defn ^:async main [] | ||
| 33 | + (app/bind!) | ||
| 34 | + (host/install!) | ||
| 35 | + ;; The WebSocket transport, where `frq.main` installs the dart:io one. Both | ||
| 36 | + ;; go in before `start!`, which asserts only on `frq.io` but connects | ||
| 37 | + ;; through `frq.net` the moment a saved session restores. | ||
| 38 | + (net-web/install!) | ||
| 39 | + (oauth-web/install!) | ||
| 40 | + ;; The app-password path: it reaches the reader's own PDS directly. Like | ||
| 41 | + ;; the OAuth one above it, and unlike freeq's broker, nothing in it cares | ||
| 42 | + ;; which origin this build is served from. | ||
| 43 | + (atproto-web/install!) | ||
| 44 | + ;; Two questions with one answer: did we just come back from the | ||
| 45 | + ;; authorization server, and failing that was there a sign-in before today. | ||
| 46 | + ;; Either way what lands is a session in `frq.cells`, which is what | ||
| 47 | + ;; `frq.main/sign-in!` finds instead of starting the browser leg again. | ||
| 48 | + (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))] | ||
| 49 | + (await (app/start!)) | ||
| 50 | + ;; A sign-in that has just completed should not then wait to be told to | ||
| 51 | + ;; connect: the reader asked for this before they left for Bluesky. | ||
| 52 | + (when signed-in? (app/connect!)))) | ||
added
flutter/web/frq_dpop.js +134 -0 | new file mode 100644 | ||
| @@ -0,0 +1,134 @@ | ||
| 1 | +// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE. | |
| 2 | +// | |
| 3 | +// JavaScript rather than ClojureDart, deliberately. What this does is | |
| 4 | +// WebCrypto — generateKey, sign, digest, exportKey — and every one of those | |
| 5 | +// speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records. | |
| 6 | +// Reaching them from cljd means dart:js_util for each value in both | |
| 7 | +// directions, and ArrayBuffer-to-bytes is the kind of conversion that fails | |
| 8 | +// at run time rather than at the compiler. Here it is the language's home | |
| 9 | +// ground, and what crosses the boundary is a string. | |
| 10 | +// | |
| 11 | +// So the contract is narrow on purpose: every function below takes strings | |
| 12 | +// and returns a string or a Promise of one. `frq.dpop.web` is the other half. | |
| 13 | +(function () { | |
| 14 | + 'use strict'; | |
| 15 | + | |
| 16 | + const enc = new TextEncoder(); | |
| 17 | + | |
| 18 | + const b64u = (buf) => | |
| 19 | + btoa(String.fromCharCode(...new Uint8Array(buf))) | |
| 20 | + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | |
| 21 | + | |
| 22 | + const ALG = { name: 'ECDSA', namedCurve: 'P-256' }; | |
| 23 | + const SIGN = { name: 'ECDSA', hash: 'SHA-256' }; | |
| 24 | + | |
| 25 | + // The key pair this client proves it holds. One per sign-in, and it must | |
| 26 | + // outlive a full-page redirect — the authorization leg leaves for the PDS | |
| 27 | + // and comes back as a fresh load — so it is kept as JWK in localStorage | |
| 28 | + // rather than as a non-extractable CryptoKey in IndexedDB. | |
| 29 | + // | |
| 30 | + // That is a deliberate trade and worth naming: an extractable key sits | |
| 31 | + // beside the access token it is bound to, in the same store, and anything | |
| 32 | + // that can read one can read the other. They share a lifetime and a blast | |
| 33 | + // radius, so the key being extractable costs nothing the token does not | |
| 34 | + // already cost — and IndexedDB interop through cljd would cost a great deal. | |
| 35 | + const KEY = 'frq:dpop:jwk'; | |
| 36 | + | |
| 37 | + let cached = null; | |
| 38 | + | |
| 39 | + async function keys() { | |
| 40 | + if (cached) return cached; | |
| 41 | + let jwk = null; | |
| 42 | + try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; } | |
| 43 | + if (!jwk) { | |
| 44 | + const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']); | |
| 45 | + jwk = await crypto.subtle.exportKey('jwk', kp.privateKey); | |
| 46 | + try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ } | |
| 47 | + } | |
| 48 | + const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']); | |
| 49 | + // The public half of the same key, which is what a proof carries in its | |
| 50 | + // header. Derived from the private JWK by dropping the private fields | |
| 51 | + // rather than exported separately, so the two cannot drift apart. | |
| 52 | + const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; | |
| 53 | + cached = { priv, pub }; | |
| 54 | + return cached; | |
| 55 | + } | |
| 56 | + | |
| 57 | + async function jws(header, payload, priv) { | |
| 58 | + const h = b64u(enc.encode(JSON.stringify(header))); | |
| 59 | + const p = b64u(enc.encode(JSON.stringify(payload))); | |
| 60 | + // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants — | |
| 61 | + // no DER unwrapping, unlike most non-browser crypto libraries. | |
| 62 | + const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p)); | |
| 63 | + return h + '.' + p + '.' + b64u(sig); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no | |
| 67 | + // convenient undefined, and an empty string is the honest "not this time". | |
| 68 | + // | |
| 69 | + // `ath` is the access token's SHA-256, and it is what lets a proof be | |
| 70 | + // minted for a request this client will never make: freeq's SASL calls the | |
| 71 | + // PDS's getSession on our behalf, with our token and our proof, and the PDS | |
| 72 | + // checks that the proof names that token and that URL. | |
| 73 | + async function proof(htm, htu, nonce, token) { | |
| 74 | + const { priv, pub } = await keys(); | |
| 75 | + const payload = { | |
| 76 | + jti: crypto.randomUUID(), | |
| 77 | + htm: htm, | |
| 78 | + htu: htu, | |
| 79 | + iat: Math.floor(Date.now() / 1000), | |
| 80 | + }; | |
| 81 | + if (nonce) payload.nonce = nonce; | |
| 82 | + if (token) { | |
| 83 | + payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token))); | |
| 84 | + } | |
| 85 | + return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv); | |
| 86 | + } | |
| 87 | + | |
| 88 | + // PKCE. The verifier is kept by the caller (it has to survive the redirect | |
| 89 | + // and `frq.io` already knows how to keep things); this only makes the pair. | |
| 90 | + function verifier() { | |
| 91 | + return b64u(crypto.getRandomValues(new Uint8Array(32))); | |
| 92 | + } | |
| 93 | + | |
| 94 | + async function challenge(verifier) { | |
| 95 | + return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier))); | |
| 96 | + } | |
| 97 | + | |
| 98 | + function random(n) { | |
| 99 | + return b64u(crypto.getRandomValues(new Uint8Array(n))); | |
| 100 | + } | |
| 101 | + | |
| 102 | + // Forget the key. Called when a session is dropped: a DPoP key outliving | |
| 103 | + // the token it was bound to is a key with nothing to prove. | |
| 104 | + function forget() { | |
| 105 | + cached = null; | |
| 106 | + try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ } | |
| 107 | + } | |
| 108 | + | |
| 109 | + // Callbacks rather than Promises, and node-style `cb(err, value)`. | |
| 110 | + // | |
| 111 | + // ClojureDart can only reach JavaScript through `dart:js` here: cljd's | |
| 112 | + // analyzer resolves that library and neither `dart:js_util` nor | |
| 113 | + // `dart:js_interop` ("Can't find Dart lib"), so there is no | |
| 114 | + // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does | |
| 115 | + // give is automatic wrapping of a Dart closure passed as an argument — so | |
| 116 | + // the Promise is unwrapped on this side and the answer handed back through | |
| 117 | + // a function call, which crosses the boundary cleanly. | |
| 118 | + const cbify = (fn) => (...args) => { | |
| 119 | + const cb = args.pop(); | |
| 120 | + Promise.resolve(fn(...args)).then( | |
| 121 | + (v) => cb('', v), | |
| 122 | + (e) => cb(String(e && e.message ? e.message : e), ''), | |
| 123 | + ); | |
| 124 | + }; | |
| 125 | + | |
| 126 | + window.frqDpop = { | |
| 127 | + proof: cbify(proof), | |
| 128 | + challenge: cbify(challenge), | |
| 129 | + // Synchronous already: no crypto to await, just random bytes. | |
| 130 | + verifier: verifier, | |
| 131 | + random: random, | |
| 132 | + forget: forget, | |
| 133 | + }; | |
| 134 | +})(); | |
| new file mode 100644 | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | +// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE. | ||
| 2 | +// | ||
| 3 | +// JavaScript rather than ClojureDart, deliberately. What this does is | ||
| 4 | +// WebCrypto — generateKey, sign, digest, exportKey — and every one of those | ||
| 5 | +// speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records. | ||
| 6 | +// Reaching them from cljd means dart:js_util for each value in both | ||
| 7 | +// directions, and ArrayBuffer-to-bytes is the kind of conversion that fails | ||
| 8 | +// at run time rather than at the compiler. Here it is the language's home | ||
| 9 | +// ground, and what crosses the boundary is a string. | ||
| 10 | +// | ||
| 11 | +// So the contract is narrow on purpose: every function below takes strings | ||
| 12 | +// and returns a string or a Promise of one. `frq.dpop.web` is the other half. | ||
| 13 | +(function () { | ||
| 14 | + 'use strict'; | ||
| 15 | + | ||
| 16 | + const enc = new TextEncoder(); | ||
| 17 | + | ||
| 18 | + const b64u = (buf) => | ||
| 19 | + btoa(String.fromCharCode(...new Uint8Array(buf))) | ||
| 20 | + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | ||
| 21 | + | ||
| 22 | + const ALG = { name: 'ECDSA', namedCurve: 'P-256' }; | ||
| 23 | + const SIGN = { name: 'ECDSA', hash: 'SHA-256' }; | ||
| 24 | + | ||
| 25 | + // The key pair this client proves it holds. One per sign-in, and it must | ||
| 26 | + // outlive a full-page redirect — the authorization leg leaves for the PDS | ||
| 27 | + // and comes back as a fresh load — so it is kept as JWK in localStorage | ||
| 28 | + // rather than as a non-extractable CryptoKey in IndexedDB. | ||
| 29 | + // | ||
| 30 | + // That is a deliberate trade and worth naming: an extractable key sits | ||
| 31 | + // beside the access token it is bound to, in the same store, and anything | ||
| 32 | + // that can read one can read the other. They share a lifetime and a blast | ||
| 33 | + // radius, so the key being extractable costs nothing the token does not | ||
| 34 | + // already cost — and IndexedDB interop through cljd would cost a great deal. | ||
| 35 | + const KEY = 'frq:dpop:jwk'; | ||
| 36 | + | ||
| 37 | + let cached = null; | ||
| 38 | + | ||
| 39 | + async function keys() { | ||
| 40 | + if (cached) return cached; | ||
| 41 | + let jwk = null; | ||
| 42 | + try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; } | ||
| 43 | + if (!jwk) { | ||
| 44 | + const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']); | ||
| 45 | + jwk = await crypto.subtle.exportKey('jwk', kp.privateKey); | ||
| 46 | + try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ } | ||
| 47 | + } | ||
| 48 | + const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']); | ||
| 49 | + // The public half of the same key, which is what a proof carries in its | ||
| 50 | + // header. Derived from the private JWK by dropping the private fields | ||
| 51 | + // rather than exported separately, so the two cannot drift apart. | ||
| 52 | + const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; | ||
| 53 | + cached = { priv, pub }; | ||
| 54 | + return cached; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + async function jws(header, payload, priv) { | ||
| 58 | + const h = b64u(enc.encode(JSON.stringify(header))); | ||
| 59 | + const p = b64u(enc.encode(JSON.stringify(payload))); | ||
| 60 | + // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants — | ||
| 61 | + // no DER unwrapping, unlike most non-browser crypto libraries. | ||
| 62 | + const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p)); | ||
| 63 | + return h + '.' + p + '.' + b64u(sig); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no | ||
| 67 | + // convenient undefined, and an empty string is the honest "not this time". | ||
| 68 | + // | ||
| 69 | + // `ath` is the access token's SHA-256, and it is what lets a proof be | ||
| 70 | + // minted for a request this client will never make: freeq's SASL calls the | ||
| 71 | + // PDS's getSession on our behalf, with our token and our proof, and the PDS | ||
| 72 | + // checks that the proof names that token and that URL. | ||
| 73 | + async function proof(htm, htu, nonce, token) { | ||
| 74 | + const { priv, pub } = await keys(); | ||
| 75 | + const payload = { | ||
| 76 | + jti: crypto.randomUUID(), | ||
| 77 | + htm: htm, | ||
| 78 | + htu: htu, | ||
| 79 | + iat: Math.floor(Date.now() / 1000), | ||
| 80 | + }; | ||
| 81 | + if (nonce) payload.nonce = nonce; | ||
| 82 | + if (token) { | ||
| 83 | + payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token))); | ||
| 84 | + } | ||
| 85 | + return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv); | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + // PKCE. The verifier is kept by the caller (it has to survive the redirect | ||
| 89 | + // and `frq.io` already knows how to keep things); this only makes the pair. | ||
| 90 | + function verifier() { | ||
| 91 | + return b64u(crypto.getRandomValues(new Uint8Array(32))); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + async function challenge(verifier) { | ||
| 95 | + return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier))); | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + function random(n) { | ||
| 99 | + return b64u(crypto.getRandomValues(new Uint8Array(n))); | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + // Forget the key. Called when a session is dropped: a DPoP key outliving | ||
| 103 | + // the token it was bound to is a key with nothing to prove. | ||
| 104 | + function forget() { | ||
| 105 | + cached = null; | ||
| 106 | + try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + // Callbacks rather than Promises, and node-style `cb(err, value)`. | ||
| 110 | + // | ||
| 111 | + // ClojureDart can only reach JavaScript through `dart:js` here: cljd's | ||
| 112 | + // analyzer resolves that library and neither `dart:js_util` nor | ||
| 113 | + // `dart:js_interop` ("Can't find Dart lib"), so there is no | ||
| 114 | + // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does | ||
| 115 | + // give is automatic wrapping of a Dart closure passed as an argument — so | ||
| 116 | + // the Promise is unwrapped on this side and the answer handed back through | ||
| 117 | + // a function call, which crosses the boundary cleanly. | ||
| 118 | + const cbify = (fn) => (...args) => { | ||
| 119 | + const cb = args.pop(); | ||
| 120 | + Promise.resolve(fn(...args)).then( | ||
| 121 | + (v) => cb('', v), | ||
| 122 | + (e) => cb(String(e && e.message ? e.message : e), ''), | ||
| 123 | + ); | ||
| 124 | + }; | ||
| 125 | + | ||
| 126 | + window.frqDpop = { | ||
| 127 | + proof: cbify(proof), | ||
| 128 | + challenge: cbify(challenge), | ||
| 129 | + // Synchronous already: no crypto to await, just random bytes. | ||
| 130 | + verifier: verifier, | ||
| 131 | + random: random, | ||
| 132 | + forget: forget, | ||
| 133 | + }; | ||
| 134 | +})(); | ||
added
flutter/web/index.html +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html> | |
| 3 | +<head> | |
| 4 | + <!-- | |
| 5 | + Committed rather than generated. `just flutter-web` used to run | |
| 6 | + `flutter create --platforms=web` when this directory was missing, which | |
| 7 | + was fine while the page was Flutter's default one — but the OAuth client | |
| 8 | + needs a script of its own beside the bundle, and a generated runner is no | |
| 9 | + place to keep one. | |
| 10 | + | |
| 11 | + `$FLUTTER_BASE_HREF` is the token `flutter build web` rewrites; leave it. | |
| 12 | + --> | |
| 13 | + <base href="$FLUTTER_BASE_HREF"> | |
| 14 | + | |
| 15 | + <meta charset="UTF-8"> | |
| 16 | + <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | |
| 17 | + <meta name="description" content="freeq client"> | |
| 18 | + | |
| 19 | + <meta name="mobile-web-app-capable" content="yes"> | |
| 20 | + <meta name="apple-mobile-web-app-status-bar-style" content="black"> | |
| 21 | + <meta name="apple-mobile-web-app-title" content="frq"> | |
| 22 | + <link rel="apple-touch-icon" href="icons/Icon-192.png"> | |
| 23 | + | |
| 24 | + <link rel="icon" type="image/png" href="favicon.png"/> | |
| 25 | + | |
| 26 | + <title>frq</title> | |
| 27 | + <link rel="manifest" href="manifest.json"> | |
| 28 | + | |
| 29 | + <!-- | |
| 30 | + WebCrypto for the OAuth client, loaded before the bundle so it is there | |
| 31 | + the moment ClojureDart asks. Not `async`: `frq.dpop.web` calls into it | |
| 32 | + during sign-in, and a helper that might not have parsed yet is a race | |
| 33 | + nobody would enjoy debugging. | |
| 34 | + --> | |
| 35 | + <script src="frq_dpop.js"></script> | |
| 36 | +</head> | |
| 37 | +<body> | |
| 38 | + <script src="flutter_bootstrap.js" async></script> | |
| 39 | +</body> | |
| 40 | +</html> | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html> | ||
| 3 | +<head> | ||
| 4 | + <!-- | ||
| 5 | + Committed rather than generated. `just flutter-web` used to run | ||
| 6 | + `flutter create --platforms=web` when this directory was missing, which | ||
| 7 | + was fine while the page was Flutter's default one — but the OAuth client | ||
| 8 | + needs a script of its own beside the bundle, and a generated runner is no | ||
| 9 | + place to keep one. | ||
| 10 | + | ||
| 11 | + `$FLUTTER_BASE_HREF` is the token `flutter build web` rewrites; leave it. | ||
| 12 | + --> | ||
| 13 | + <base href="$FLUTTER_BASE_HREF"> | ||
| 14 | + | ||
| 15 | + <meta charset="UTF-8"> | ||
| 16 | + <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||
| 17 | + <meta name="description" content="freeq client"> | ||
| 18 | + | ||
| 19 | + <meta name="mobile-web-app-capable" content="yes"> | ||
| 20 | + <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||
| 21 | + <meta name="apple-mobile-web-app-title" content="frq"> | ||
| 22 | + <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||
| 23 | + | ||
| 24 | + <link rel="icon" type="image/png" href="favicon.png"/> | ||
| 25 | + | ||
| 26 | + <title>frq</title> | ||
| 27 | + <link rel="manifest" href="manifest.json"> | ||
| 28 | + | ||
| 29 | + <!-- | ||
| 30 | + WebCrypto for the OAuth client, loaded before the bundle so it is there | ||
| 31 | + the moment ClojureDart asks. Not `async`: `frq.dpop.web` calls into it | ||
| 32 | + during sign-in, and a helper that might not have parsed yet is a race | ||
| 33 | + nobody would enjoy debugging. | ||
| 34 | + --> | ||
| 35 | + <script src="frq_dpop.js"></script> | ||
| 36 | +</head> | ||
| 37 | +<body> | ||
| 38 | + <script src="flutter_bootstrap.js" async></script> | ||
| 39 | +</body> | ||
| 40 | +</html> | ||
modified
justfile +127 -0 | @@ -420,6 +420,94 @@ flutter-desktop action="build": | ||
| 420 | 420 | *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; |
| 421 | 421 | esac |
| 422 | 422 | |
| 423 | +# The third frontend: the same screens again, compiled to JavaScript. | |
| 424 | +# | |
| 425 | +# `flutter-desktop` with the Linux half taken out. One `clojure -M:cljd | |
| 426 | +# compile` over the same flutter/src and common/, then Flutter's web target | |
| 427 | +# instead of its Linux one — dart2js instead of CMake and Ninja, and a | |
| 428 | +# directory of static files instead of a bundle with an executable in it. | |
| 429 | +# | |
| 430 | +# Impure for the one reason the other two are and not the other: pub.dev | |
| 431 | +# resolution and Flutter's engine artifacts are network. There is no | |
| 432 | +# writable-SDK dance and no nixGL, because nothing here writes into the store | |
| 433 | +# and nothing here paints — the browser does both. | |
| 434 | +# | |
| 435 | +# The entry point is `frq.main-web`, not `frq.main`: path_provider has no web | |
| 436 | +# implementation, so the `getApplicationSupportDirectory` that `frq.main` | |
| 437 | +# awaits throws MissingPluginException before any widget is built. The web | |
| 438 | +# entry installs `frq.io.web` — localStorage behind the same seam — and awaits | |
| 439 | +# nothing. `frq.net.dart` is still the socket half, so connecting will want a | |
| 440 | +# WebSocket before this does more than paint. | |
| 441 | +# | |
| 442 | +# just flutter-web build build/web | |
| 443 | +# just flutter-web serve build it and serve it on $PORT (8080) | |
| 444 | +flutter-web action="build" port="8080": | |
| 445 | + #!/usr/bin/env bash | |
| 446 | + set -euo pipefail | |
| 447 | + cd "{{justfile_directory()}}" | |
| 448 | + if [ -z "${FRQ_FLUTTER_WEB:-}" ]; then | |
| 449 | + exec {{nix}} develop .#flutter-web --max-jobs {{jobs}} \ | |
| 450 | + --command just flutter-web "$@" | |
| 451 | + fi | |
| 452 | + cd flutter | |
| 453 | + | |
| 454 | + # The same three caches `apk` and `flutter-desktop` seed, in the same | |
| 455 | + # place and out of the same flake output. `.home/` is `apk`'s directory by | |
| 456 | + # name and all three write only this into it: whichever recipe runs first | |
| 457 | + # pays for the copy and the other two find it warm. | |
| 458 | + # | |
| 459 | + # m2 and gitlibs are set here for the reason they are set there — the JVM | |
| 460 | + # reads user.home out of /etc/passwd, so neither follows HOME. | |
| 461 | + export PUB_CACHE="$PWD/.home/.pub-cache" | |
| 462 | + export GITLIBS="$PWD/.home/gitlibs" | |
| 463 | + m2="$PWD/.home/m2" | |
| 464 | + | |
| 465 | + seed() { | |
| 466 | + [ -e "$2" ] && return 0 | |
| 467 | + mkdir -p "$(dirname "$2")" | |
| 468 | + cp -r "$FRQ_CLJD_DEPS/$1" "$2" | |
| 469 | + chmod -R u+w "$2" | |
| 470 | + } | |
| 471 | + seed m2 "$m2" | |
| 472 | + seed gitlibs "$GITLIBS" | |
| 473 | + seed pub-cache "$PUB_CACHE" | |
| 474 | + seed clojuredart/cache "$PWD/.clojuredart/cache" | |
| 475 | + | |
| 476 | + # Resolved here rather than in cljd-deps, which was not allowed to name | |
| 477 | + # the store — see the same loop in `apk`. | |
| 478 | + for helper in .clojuredart/cache/*/cljd_helper; do | |
| 479 | + [ -d "$helper" ] || continue | |
| 480 | + [ -e "$helper/.dart_tool/package_config.json" ] && continue | |
| 481 | + ( cd "$helper" && flutter pub get --offline ) | |
| 482 | + done | |
| 483 | + | |
| 484 | + # The web target is off in a checkout created for Android and Linux. | |
| 485 | + # `flutter/web/` itself IS committed now, unlike the Android and Linux | |
| 486 | + # runners: the OAuth client needs a script of its own beside the bundle | |
| 487 | + # (see web/frq_dpop.js), and a directory `flutter create` regenerates is | |
| 488 | + # no place to keep one. | |
| 489 | + flutter config --enable-web >/dev/null || true | |
| 490 | + | |
| 491 | + # `frq.main-web` and not `frq.main`: the compile walks out from the | |
| 492 | + # namespace it is given, which is what keeps `dart:html` in the web build | |
| 493 | + # and out of the other two. `flutter/lib/main_web.dart` is the one-line | |
| 494 | + # export beside the generated `main.dart` that -t points at. | |
| 495 | + clojure -Sdeps "{:mvn/local-repo \"$m2\"}" -M:cljd compile frq.main-web | |
| 496 | + flutter build web -t lib/main_web.dart | |
| 497 | + | |
| 498 | + case "{{action}}" in | |
| 499 | + build) echo "built $PWD/build/web" ;; | |
| 500 | + serve) | |
| 501 | + echo "serving $PWD/build/web on :{{port}}" | |
| 502 | + # --bind 0.0.0.0 and not the default loopback: in the container | |
| 503 | + # this is behind a Modal tunnel, and a server bound to 127.0.0.1 | |
| 504 | + # is one the tunnel cannot reach. | |
| 505 | + exec python3 -m http.server {{port}} --bind 0.0.0.0 \ | |
| 506 | + --directory build/web | |
| 507 | + ;; | |
| 508 | + *) echo "usage: just flutter-web [build|serve]" >&2; exit 1 ;; | |
| 509 | + esac | |
| 510 | + | |
| 423 | 511 | # The containers in `.modal/`, run on Modal rather than here. This machine |
| 424 | 512 | # evaluates and Modal builds — see CLAUDE.md, which says so rather more |
| 425 | 513 | # firmly — and these two recipes are the whole interface to that. |
| @@ -437,6 +525,45 @@ modal container="frq" *args: | ||
| 437 | 525 | shift |
| 438 | 526 | exec modal run ".modal/{{container}}/container.py" "$@" |
| 439 | 527 | |
| 528 | +# The Modal-built web bundle, served from this machine on localhost. | |
| 529 | +# | |
| 530 | +# Not a local build: `modal volume get` pulls what `just modal flutter-web` | |
| 531 | +# already compiled out of the devshell volume, so this needs no Flutter, no | |
| 532 | +# Dart and no nix — only python, which the flake shell has and so does the | |
| 533 | +# machine. | |
| 534 | +# | |
| 535 | +# It exists for one reason, and the reason is the auth broker rather than | |
| 536 | +# convenience. freeq's broker finishes an OAuth login by redirecting the | |
| 537 | +# browser to `return_to`, and it will only redirect to an origin on its | |
| 538 | +# allowlist: its own https hosts, and `http://localhost` or `http://127.0.0.1` | |
| 539 | +# on ANY port. A build served from anywhere else — the Modal URL included — | |
| 540 | +# gets `400 Invalid return_to URL` and can never complete a Bluesky sign-in, | |
| 541 | +# no matter what the client does. localhost is the one allowlisted origin we | |
| 542 | +# can serve from, so this is how Bluesky sign-in is tested. | |
| 543 | +# | |
| 544 | +# Guest and app-password sign-in need none of this; they work on the deployed | |
| 545 | +# URL, because neither goes near the broker. | |
| 546 | +# | |
| 547 | +# just web-local fetch and serve on :8080 | |
| 548 | +# just web-local 3000 another port; any port is allowlisted | |
| 549 | +web-local port="8080": | |
| 550 | + #!/usr/bin/env bash | |
| 551 | + set -euo pipefail | |
| 552 | + cd "{{justfile_directory()}}" | |
| 553 | + out="{{justfile_directory()}}/.web-local" | |
| 554 | + mkdir -p "$out" | |
| 555 | + echo "fetching the Modal-built bundle…" | |
| 556 | + # --force: this is a mirror of the volume, and a stale file left behind | |
| 557 | + # would be served in preference to the one just built. | |
| 558 | + modal volume get --force devshell frq-flutter-web/flutter/build/web "$out" | |
| 559 | + echo | |
| 560 | + echo " http://localhost:{{port}}" | |
| 561 | + echo | |
| 562 | + echo "Bluesky sign-in works here and not on the Modal URL: the broker" | |
| 563 | + echo "allowlists localhost on any port. Put wss://irc.freeq.at/irc in the" | |
| 564 | + echo "Server field — a browser has no TCP." | |
| 565 | + exec python3 -m http.server {{port}} --bind 127.0.0.1 --directory "$out/web" | |
| 566 | + | |
| 440 | 567 | # A sandbox left running with the container's own image, volumes and |
| 441 | 568 | # environment, and the command to get into it. `modal shell --image` cannot |
| 442 | 569 | # be pointed at a published Modal image like arch-nix, so attaching to a |
| @@ -420,6 +420,94 @@ flutter-desktop action="build": | |||
| 420 | *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; | 420 | *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; |
| 421 | esac | 421 | esac |
| 422 | 422 | ||
| 423 | +# The third frontend: the same screens again, compiled to JavaScript. | ||
| 424 | +# | ||
| 425 | +# `flutter-desktop` with the Linux half taken out. One `clojure -M:cljd | ||
| 426 | +# compile` over the same flutter/src and common/, then Flutter's web target | ||
| 427 | +# instead of its Linux one — dart2js instead of CMake and Ninja, and a | ||
| 428 | +# directory of static files instead of a bundle with an executable in it. | ||
| 429 | +# | ||
| 430 | +# Impure for the one reason the other two are and not the other: pub.dev | ||
| 431 | +# resolution and Flutter's engine artifacts are network. There is no | ||
| 432 | +# writable-SDK dance and no nixGL, because nothing here writes into the store | ||
| 433 | +# and nothing here paints — the browser does both. | ||
| 434 | +# | ||
| 435 | +# The entry point is `frq.main-web`, not `frq.main`: path_provider has no web | ||
| 436 | +# implementation, so the `getApplicationSupportDirectory` that `frq.main` | ||
| 437 | +# awaits throws MissingPluginException before any widget is built. The web | ||
| 438 | +# entry installs `frq.io.web` — localStorage behind the same seam — and awaits | ||
| 439 | +# nothing. `frq.net.dart` is still the socket half, so connecting will want a | ||
| 440 | +# WebSocket before this does more than paint. | ||
| 441 | +# | ||
| 442 | +# just flutter-web build build/web | ||
| 443 | +# just flutter-web serve build it and serve it on $PORT (8080) | ||
| 444 | +flutter-web action="build" port="8080": | ||
| 445 | + #!/usr/bin/env bash | ||
| 446 | + set -euo pipefail | ||
| 447 | + cd "{{justfile_directory()}}" | ||
| 448 | + if [ -z "${FRQ_FLUTTER_WEB:-}" ]; then | ||
| 449 | + exec {{nix}} develop .#flutter-web --max-jobs {{jobs}} \ | ||
| 450 | + --command just flutter-web "$@" | ||
| 451 | + fi | ||
| 452 | + cd flutter | ||
| 453 | + | ||
| 454 | + # The same three caches `apk` and `flutter-desktop` seed, in the same | ||
| 455 | + # place and out of the same flake output. `.home/` is `apk`'s directory by | ||
| 456 | + # name and all three write only this into it: whichever recipe runs first | ||
| 457 | + # pays for the copy and the other two find it warm. | ||
| 458 | + # | ||
| 459 | + # m2 and gitlibs are set here for the reason they are set there — the JVM | ||
| 460 | + # reads user.home out of /etc/passwd, so neither follows HOME. | ||
| 461 | + export PUB_CACHE="$PWD/.home/.pub-cache" | ||
| 462 | + export GITLIBS="$PWD/.home/gitlibs" | ||
| 463 | + m2="$PWD/.home/m2" | ||
| 464 | + | ||
| 465 | + seed() { | ||
| 466 | + [ -e "$2" ] && return 0 | ||
| 467 | + mkdir -p "$(dirname "$2")" | ||
| 468 | + cp -r "$FRQ_CLJD_DEPS/$1" "$2" | ||
| 469 | + chmod -R u+w "$2" | ||
| 470 | + } | ||
| 471 | + seed m2 "$m2" | ||
| 472 | + seed gitlibs "$GITLIBS" | ||
| 473 | + seed pub-cache "$PUB_CACHE" | ||
| 474 | + seed clojuredart/cache "$PWD/.clojuredart/cache" | ||
| 475 | + | ||
| 476 | + # Resolved here rather than in cljd-deps, which was not allowed to name | ||
| 477 | + # the store — see the same loop in `apk`. | ||
| 478 | + for helper in .clojuredart/cache/*/cljd_helper; do | ||
| 479 | + [ -d "$helper" ] || continue | ||
| 480 | + [ -e "$helper/.dart_tool/package_config.json" ] && continue | ||
| 481 | + ( cd "$helper" && flutter pub get --offline ) | ||
| 482 | + done | ||
| 483 | + | ||
| 484 | + # The web target is off in a checkout created for Android and Linux. | ||
| 485 | + # `flutter/web/` itself IS committed now, unlike the Android and Linux | ||
| 486 | + # runners: the OAuth client needs a script of its own beside the bundle | ||
| 487 | + # (see web/frq_dpop.js), and a directory `flutter create` regenerates is | ||
| 488 | + # no place to keep one. | ||
| 489 | + flutter config --enable-web >/dev/null || true | ||
| 490 | + | ||
| 491 | + # `frq.main-web` and not `frq.main`: the compile walks out from the | ||
| 492 | + # namespace it is given, which is what keeps `dart:html` in the web build | ||
| 493 | + # and out of the other two. `flutter/lib/main_web.dart` is the one-line | ||
| 494 | + # export beside the generated `main.dart` that -t points at. | ||
| 495 | + clojure -Sdeps "{:mvn/local-repo \"$m2\"}" -M:cljd compile frq.main-web | ||
| 496 | + flutter build web -t lib/main_web.dart | ||
| 497 | + | ||
| 498 | + case "{{action}}" in | ||
| 499 | + build) echo "built $PWD/build/web" ;; | ||
| 500 | + serve) | ||
| 501 | + echo "serving $PWD/build/web on :{{port}}" | ||
| 502 | + # --bind 0.0.0.0 and not the default loopback: in the container | ||
| 503 | + # this is behind a Modal tunnel, and a server bound to 127.0.0.1 | ||
| 504 | + # is one the tunnel cannot reach. | ||
| 505 | + exec python3 -m http.server {{port}} --bind 0.0.0.0 \ | ||
| 506 | + --directory build/web | ||
| 507 | + ;; | ||
| 508 | + *) echo "usage: just flutter-web [build|serve]" >&2; exit 1 ;; | ||
| 509 | + esac | ||
| 510 | + | ||
| 423 | # The containers in `.modal/`, run on Modal rather than here. This machine | 511 | # The containers in `.modal/`, run on Modal rather than here. This machine |
| 424 | # evaluates and Modal builds — see CLAUDE.md, which says so rather more | 512 | # evaluates and Modal builds — see CLAUDE.md, which says so rather more |
| 425 | # firmly — and these two recipes are the whole interface to that. | 513 | # firmly — and these two recipes are the whole interface to that. |
| @@ -437,6 +525,45 @@ modal container="frq" *args: | |||
| 437 | shift | 525 | shift |
| 438 | exec modal run ".modal/{{container}}/container.py" "$@" | 526 | exec modal run ".modal/{{container}}/container.py" "$@" |
| 439 | 527 | ||
| 528 | +# The Modal-built web bundle, served from this machine on localhost. | ||
| 529 | +# | ||
| 530 | +# Not a local build: `modal volume get` pulls what `just modal flutter-web` | ||
| 531 | +# already compiled out of the devshell volume, so this needs no Flutter, no | ||
| 532 | +# Dart and no nix — only python, which the flake shell has and so does the | ||
| 533 | +# machine. | ||
| 534 | +# | ||
| 535 | +# It exists for one reason, and the reason is the auth broker rather than | ||
| 536 | +# convenience. freeq's broker finishes an OAuth login by redirecting the | ||
| 537 | +# browser to `return_to`, and it will only redirect to an origin on its | ||
| 538 | +# allowlist: its own https hosts, and `http://localhost` or `http://127.0.0.1` | ||
| 539 | +# on ANY port. A build served from anywhere else — the Modal URL included — | ||
| 540 | +# gets `400 Invalid return_to URL` and can never complete a Bluesky sign-in, | ||
| 541 | +# no matter what the client does. localhost is the one allowlisted origin we | ||
| 542 | +# can serve from, so this is how Bluesky sign-in is tested. | ||
| 543 | +# | ||
| 544 | +# Guest and app-password sign-in need none of this; they work on the deployed | ||
| 545 | +# URL, because neither goes near the broker. | ||
| 546 | +# | ||
| 547 | +# just web-local fetch and serve on :8080 | ||
| 548 | +# just web-local 3000 another port; any port is allowlisted | ||
| 549 | +web-local port="8080": | ||
| 550 | + #!/usr/bin/env bash | ||
| 551 | + set -euo pipefail | ||
| 552 | + cd "{{justfile_directory()}}" | ||
| 553 | + out="{{justfile_directory()}}/.web-local" | ||
| 554 | + mkdir -p "$out" | ||
| 555 | + echo "fetching the Modal-built bundle…" | ||
| 556 | + # --force: this is a mirror of the volume, and a stale file left behind | ||
| 557 | + # would be served in preference to the one just built. | ||
| 558 | + modal volume get --force devshell frq-flutter-web/flutter/build/web "$out" | ||
| 559 | + echo | ||
| 560 | + echo " http://localhost:{{port}}" | ||
| 561 | + echo | ||
| 562 | + echo "Bluesky sign-in works here and not on the Modal URL: the broker" | ||
| 563 | + echo "allowlists localhost on any port. Put wss://irc.freeq.at/irc in the" | ||
| 564 | + echo "Server field — a browser has no TCP." | ||
| 565 | + exec python3 -m http.server {{port}} --bind 127.0.0.1 --directory "$out/web" | ||
| 566 | + | ||
| 440 | # A sandbox left running with the container's own image, volumes and | 567 | # A sandbox left running with the container's own image, volumes and |
| 441 | # environment, and the command to get into it. `modal shell --image` cannot | 568 | # environment, and the command to get into it. `modal shell --image` cannot |
| 442 | # be pointed at a published Modal image like arch-nix, so attaching to a | 569 | # be pointed at a published Modal image like arch-nix, so attaching to a |