nandi/frqpublic Fork 0
f54ca45
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-17T00:56:23-07:00 Browse files
f54ca45 parent: e853593
modified .gitignore +3 -0
@@ -22,3 +22,6 @@
2222
2323 # Python bytecode, from the loader the .modal/ containers import.
2424 __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:
7474 # directory, a dataset too big to bake in.
7575 self.volume_spec = dict(spec.get("volumes", {}))
7676
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+
7787 # Modal re-imports this module inside the container, so everything
7888 # below runs twice: once here, once out there. Out there the local
7989 # tree does not exist -- no flake.nix, no ptyshim.c, no repo -- and
@@ -125,6 +135,11 @@ class Container:
125135 )
126136 if self.use_shim and not os.path.exists(PTYSHIM_C):
127137 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+ )
128143 for name, mount in self.volume_spec.items():
129144 if not isinstance(mount, str) or not mount.startswith("/"):
130145 raise SpecError(
@@ -226,6 +241,31 @@ class Container:
226241 # the container directory -- a container that builds the repo it lives
227242 # in sets context = "../..", so include = ["."] means the whole repo.
228243 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)
229269 # `ignore` is what keeps a build tree out of the image. A checkout that
230270 # has been built in locally carries its output -- flutter/build and the
231271 # caches beside it were 395MB of a 441MB repo -- and all of it would be
@@ -341,6 +381,8 @@ class Container:
341381 kwargs["experimental_options"] = dict(opts)
342382 if volumes := self.volumes:
343383 kwargs["volumes"] = volumes
384+ if self.ports:
385+ kwargs["encrypted_ports"] = list(self.ports)
344386 return kwargs
345387
346388 def shell_command(self, override: str = "") -> str:
@@ -376,6 +418,7 @@ class Container:
376418 env={k: str(v) for k, v in self.env.items()},
377419 **self.sandbox_kwargs,
378420 )
421+ self._print_tunnels(sb)
379422 sb.wait()
380423 if sb.returncode != 0:
381424 raise RuntimeError(
@@ -384,6 +427,19 @@ class Container:
384427 )
385428 return ""
386429
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+
387443 def open_sandbox(self) -> "modal.Sandbox":
388444 """Start a Sandbox and leave it running, for `scripts/shell`.
389445
@@ -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 everything87 # Modal re-imports this module inside the container, so everything
78 # below runs twice: once here, once out there. Out there the local88 # 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 -- and89 # 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 lives241 # 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 that269 # `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 the270 # 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 be271 # 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"] = volumes383 kwargs["volumes"] = volumes
384+ if self.ports:
385+ kwargs["encrypted_ports"] = list(self.ports)
344 return kwargs386 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 @@
10411041 # by hand.
10421042 FRQ_FLUTTER_DESKTOP = "1";
10431043 };
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+ };
10441083 });
10451084
10461085 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":
420420 *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;;
421421 esac
422422
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+
423511 # The containers in `.modal/`, run on Modal rather than here. This machine
424512 # evaluates and Modal builds — see CLAUDE.md, which says so rather more
425513 # firmly — and these two recipes are the whole interface to that.
@@ -437,6 +525,45 @@ modal container="frq" *args:
437525 shift
438526 exec modal run ".modal/{{container}}/container.py" "$@"
439527
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+
440567 # A sandbox left running with the container's own image, volumes and
441568 # environment, and the command to get into it. `modal shell --image` cannot
442569 # 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 esac421 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 machine511 # The containers in `.modal/`, run on Modal rather than here. This machine
424 # evaluates and Modal builds — see CLAUDE.md, which says so rather more512 # 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 shift525 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 and567 # A sandbox left running with the container's own image, volumes and
441 # environment, and the command to get into it. `modal shell --image` cannot568 # 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 a569 # be pointed at a published Modal image like arch-nix, so attaching to a