nandi/frqpublic Fork
⑂ 364ea65
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.

Download ZIP

No service worker, and nothing cached by name

Four deploys today, and every one of them ended with "hard-reload
first" -- twice a working fix read as broken because the page was the
previous bundle. That is not a thing to keep apologising for in a
message; it is a bug in what we serve.

Two causes, both dealt with here.

Flutter installs a service worker for offline use, and this app has no
offline: it is a window onto a live IRC connection, so a cached copy
of it can show nothing anybody wants. What it did instead was hold a
deploy back -- a new worker downloads, but the spec leaves it
*waiting* until every tab on the origin is closed, so reloading kept
serving the old one and the only cure was clearing the site data by
hand. `--pwa-strategy=none` in both places that build the bundle. It
also retires the workers already installed: with no
`flutter_service_worker.js` left to fetch, a browser update check 404s
and drops the registration. Verified in a browser against the built
bundle -- no registrations, no controller, no caches.

And `tools/webserve.py` asks for revalidation on everything it serves.
The usual rule is to cache hashed assets forever and revalidate the
few stable names, but nothing in this bundle is hashed: `main.dart.js`,
`frq_core.js`, `flutter_bootstrap.js` all keep their names and change
their contents on every deploy, so a name says nothing about what is
behind it. `no-cache` is not `no-store` -- the copy is kept, and an
unchanged file comes back 304 with no body.

One thing found on the way, and it had been failing silently: the
build recipe is a single-quoted `bash -c`, and the apostrophe in a
comment I put inside it ended the string. `flutter build web` never
ran and `just build web` still exited 0. The comment lives outside the
quotes now, with a note saying why.

Two tests: the served headers and their 304, and the fields that ask
the keyboard for no help -- the tree carrying a `verbatim` prop proves
nothing if the renderer drops it, which it did when reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T15:50:05-07:00 Browse files
364ea65 parent: fc42e9b
modified .modal/web/Dockerfile +9 -4
@@ -29,7 +29,10 @@ WORKDIR /src
2929 COPY . .
3030
3131 # The same two steps as `just build web`, inlined because `just` is not here
32-# and is not worth an install for one call. The core is compiled to
32+# and is not worth an install for one call. Inlined means duplicated, so the
33+# flags have to be kept in step with that recipe -- `--pwa-strategy=none`
34+# below is one of them, and the deployment is the place it matters most,
35+# being the one people reach for by URL. The core is compiled to
3336 # JavaScript and copied into `flutter/web/` rather than into the output,
3437 # because `flutter build web` copies that directory into the bundle -- which
3538 # is what makes the page's `<script src="frq_core.js">` resolve the same
@@ -47,12 +50,14 @@ RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
4750 nim js -d:release --hints:off --path:src --path:web \
4851 --out:/src/build/web/frq_core.js web/frq_web.nim && \
4952 cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \
50- cd /src/flutter && flutter pub get && flutter build web'
53+ cd /src/flutter && flutter pub get && \
54+ flutter build web --pwa-strategy=none'
5155
5256 # python:*-slim and not debian:*-slim, for two reasons that happen to agree:
5357 # Modal runs its own client inside the container, so the image needs a Python
54-# it can use, and the server is `http.server` -- the same one `just run web`
55-# starts on localhost, so what is served here is served the same way there.
58+# it can use, and the server is `tools/webserve.py` -- the same one
59+# `just run web` starts on localhost, so what is served here is served the
60+# same way there.
5661 FROM python:3.13-slim
5762
5863 COPY --from=build /src/flutter/build/web /srv/web
@@ -29,7 +29,10 @@ WORKDIR /src
29 COPY . .29 COPY . .
30 30
31 # The same two steps as `just build web`, inlined because `just` is not here31 # The same two steps as `just build web`, inlined because `just` is not here
32-# and is not worth an install for one call. The core is compiled to32+# and is not worth an install for one call. Inlined means duplicated, so the
33+# flags have to be kept in step with that recipe -- `--pwa-strategy=none`
34+# below is one of them, and the deployment is the place it matters most,
35+# being the one people reach for by URL. The core is compiled to
33 # JavaScript and copied into `flutter/web/` rather than into the output,36 # JavaScript and copied into `flutter/web/` rather than into the output,
34 # because `flutter build web` copies that directory into the bundle -- which37 # because `flutter build web` copies that directory into the bundle -- which
35 # is what makes the page's `<script src="frq_core.js">` resolve the same38 # is what makes the page's `<script src="frq_core.js">` resolve the same
@@ -47,12 +50,14 @@ RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\
47 nim js -d:release --hints:off --path:src --path:web \50 nim js -d:release --hints:off --path:src --path:web \
48 --out:/src/build/web/frq_core.js web/frq_web.nim && \51 --out:/src/build/web/frq_core.js web/frq_web.nim && \
49 cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \52 cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \
50- cd /src/flutter && flutter pub get && flutter build web'53+ cd /src/flutter && flutter pub get && \
54+ flutter build web --pwa-strategy=none'
51 55
52 # python:*-slim and not debian:*-slim, for two reasons that happen to agree:56 # python:*-slim and not debian:*-slim, for two reasons that happen to agree:
53 # Modal runs its own client inside the container, so the image needs a Python57 # Modal runs its own client inside the container, so the image needs a Python
54-# it can use, and the server is `http.server` -- the same one `just run web`58+# it can use, and the server is `tools/webserve.py` -- the same one
55-# starts on localhost, so what is served here is served the same way there.59+# `just run web` starts on localhost, so what is served here is served the
60+# same way there.
56 FROM python:3.13-slim61 FROM python:3.13-slim
57 62
58 COPY --from=build /src/flutter/build/web /srv/web63 COPY --from=build /src/flutter/build/web /srv/web
modified flutter/test/nim_layout_test.dart +22 -0
@@ -618,4 +618,26 @@ void main() {
618618 reason: 'the arrow button is back');
619619 });
620620 });
621+
622+ group('a field holding an identifier', () {
623+ testWidgets('asks the keyboard for no help at all', (tester) async {
624+ // A phone keyboard puts a space after a full stop, because a full stop
625+ // ends a sentence — and is also the middle of `alice.bsky.social`.
626+ // These are the properties that turn that off; the tree carrying a
627+ // `verbatim` prop proves nothing if the renderer drops it.
628+ core.dispatch('screen.connect');
629+ core.dispatch('auth.mode:bluesky');
630+ await layOut(tester, sizes['phone']!);
631+
632+ final fields = tester.widgetList<TextField>(find.byType(TextField));
633+ expect(fields, isNotEmpty);
634+ for (final f in fields) {
635+ expect(f.autocorrect, isFalse, reason: 'autocorrect is on');
636+ expect(f.enableSuggestions, isFalse, reason: 'suggestions are on');
637+ expect(f.textCapitalization, TextCapitalization.none);
638+ expect(f.keyboardType, TextInputType.url);
639+ }
640+ expectLaidOut(tester, 'the connect fields');
641+ });
642+ });
621643 }
@@ -618,4 +618,26 @@ void main() {
618 reason: 'the arrow button is back');618 reason: 'the arrow button is back');
619 });619 });
620 });620 });
621+
622+ group('a field holding an identifier', () {
623+ testWidgets('asks the keyboard for no help at all', (tester) async {
624+ // A phone keyboard puts a space after a full stop, because a full stop
625+ // ends a sentence — and is also the middle of `alice.bsky.social`.
626+ // These are the properties that turn that off; the tree carrying a
627+ // `verbatim` prop proves nothing if the renderer drops it.
628+ core.dispatch('screen.connect');
629+ core.dispatch('auth.mode:bluesky');
630+ await layOut(tester, sizes['phone']!);
631+
632+ final fields = tester.widgetList<TextField>(find.byType(TextField));
633+ expect(fields, isNotEmpty);
634+ for (final f in fields) {
635+ expect(f.autocorrect, isFalse, reason: 'autocorrect is on');
636+ expect(f.enableSuggestions, isFalse, reason: 'suggestions are on');
637+ expect(f.textCapitalization, TextCapitalization.none);
638+ expect(f.keyboardType, TextInputType.url);
639+ }
640+ expectLaidOut(tester, 'the connect fields');
641+ });
642+ });
621 }643 }
modified justfile +17 -1
@@ -194,10 +194,26 @@ _web-bundle:
194194 # is `localhost` only when it is. Guest works everywhere.
195195 python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \
196196 > flutter/web/client-metadata.json
197+ # No service worker, via `--pwa-strategy=none` below. Flutter installs
198+ # one for offline use and this app has no offline: it is a window onto a
199+ # live IRC connection, so a cached copy of it can show nothing anybody
200+ # wants. What the worker did instead was hold a deploy back -- a new one
201+ # downloads, but the spec leaves it *waiting* until every tab on the
202+ # origin is closed, so reloading kept serving the previous bundle and
203+ # the only cure was clearing the site data by hand. Twice in one
204+ # afternoon a fix looked broken because of it.
205+ #
206+ # It also retires the workers already installed out there: with no
207+ # `flutter_service_worker.js` left to fetch, a browser update check 404s
208+ # and the registration is dropped.
209+ #
210+ # The comment lives out here rather than inside the quoted script below,
211+ # which is single-quoted -- an apostrophe in there ends the string, and
212+ # the build silently stopped after `pub get` and still exited 0.
197213 exec "{{tc}}" exec -- bash -euo pipefail -c '
198214 cd flutter
199215 flutter pub get
200- flutter build web
216+ flutter build web --pwa-strategy=none
201217 rm -rf ../build/web
202218 cp -r build/web ../build/web
203219 echo "built build/web"'
@@ -194,10 +194,26 @@ _web-bundle:
194 # is `localhost` only when it is. Guest works everywhere.194 # is `localhost` only when it is. Guest works everywhere.
195 python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \195 python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \
196 > flutter/web/client-metadata.json196 > flutter/web/client-metadata.json
197+ # No service worker, via `--pwa-strategy=none` below. Flutter installs
198+ # one for offline use and this app has no offline: it is a window onto a
199+ # live IRC connection, so a cached copy of it can show nothing anybody
200+ # wants. What the worker did instead was hold a deploy back -- a new one
201+ # downloads, but the spec leaves it *waiting* until every tab on the
202+ # origin is closed, so reloading kept serving the previous bundle and
203+ # the only cure was clearing the site data by hand. Twice in one
204+ # afternoon a fix looked broken because of it.
205+ #
206+ # It also retires the workers already installed out there: with no
207+ # `flutter_service_worker.js` left to fetch, a browser update check 404s
208+ # and the registration is dropped.
209+ #
210+ # The comment lives out here rather than inside the quoted script below,
211+ # which is single-quoted -- an apostrophe in there ends the string, and
212+ # the build silently stopped after `pub get` and still exited 0.
197 exec "{{tc}}" exec -- bash -euo pipefail -c '213 exec "{{tc}}" exec -- bash -euo pipefail -c '
198 cd flutter214 cd flutter
199 flutter pub get215 flutter pub get
200- flutter build web216+ flutter build web --pwa-strategy=none
201 rm -rf ../build/web217 rm -rf ../build/web
202 cp -r build/web ../build/web218 cp -r build/web ../build/web
203 echo "built build/web"'219 echo "built build/web"'
modified nim/web/test/serve.py +21 -0
@@ -81,6 +81,27 @@ def main():
8181 assert b"across the origin boundary" in r.read()
8282 ok("it still serves the bundle")
8383
84+ # Every name in this bundle is stable and its contents change on
85+ # every deploy, so a cached copy is a stale one and the browser has
86+ # no way to know. `no-cache` keeps the copy and revalidates it.
87+ with urllib.request.urlopen(base + "/serve.py", timeout=5) as r:
88+ assert r.headers.get("Cache-Control") == "no-cache", \
89+ r.headers.get("Cache-Control")
90+ ok("and asks the browser to revalidate what it serves")
91+
92+ # A conditional request still answers 304, which is the whole reason
93+ # this costs a round trip rather than a download.
94+ req = urllib.request.Request(base + "/serve.py", headers={
95+ "If-Modified-Since": r.headers.get("Last-Modified")})
96+ code = 0
97+ try:
98+ with urllib.request.urlopen(req, timeout=5) as r2:
99+ code = r2.status
100+ except urllib.error.HTTPError as e:
101+ code = e.code
102+ assert code == 304, code
103+ ok("and an unchanged file comes back 304, with no body")
104+
84105 # The relay, with the body and its multipart boundary intact --
85106 # without the Content-Type the body is unreadable at the far end.
86107 status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--",
@@ -81,6 +81,27 @@ def main():
81 assert b"across the origin boundary" in r.read()81 assert b"across the origin boundary" in r.read()
82 ok("it still serves the bundle")82 ok("it still serves the bundle")
83 83
84+ # Every name in this bundle is stable and its contents change on
85+ # every deploy, so a cached copy is a stale one and the browser has
86+ # no way to know. `no-cache` keeps the copy and revalidates it.
87+ with urllib.request.urlopen(base + "/serve.py", timeout=5) as r:
88+ assert r.headers.get("Cache-Control") == "no-cache", \
89+ r.headers.get("Cache-Control")
90+ ok("and asks the browser to revalidate what it serves")
91+
92+ # A conditional request still answers 304, which is the whole reason
93+ # this costs a round trip rather than a download.
94+ req = urllib.request.Request(base + "/serve.py", headers={
95+ "If-Modified-Since": r.headers.get("Last-Modified")})
96+ code = 0
97+ try:
98+ with urllib.request.urlopen(req, timeout=5) as r2:
99+ code = r2.status
100+ except urllib.error.HTTPError as e:
101+ code = e.code
102+ assert code == 304, code
103+ ok("and an unchanged file comes back 304, with no body")
104+
84 # The relay, with the body and its multipart boundary intact --105 # The relay, with the body and its multipart boundary intact --
85 # without the Content-Type the body is unreadable at the far end.106 # without the Content-Type the body is unreadable at the far end.
86 status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--",107 status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--",
modified tools/webserve.py +26 -0
@@ -45,6 +45,32 @@ LIMIT = 12 * 1024 * 1024
4545
4646
4747 class Handler(SimpleHTTPRequestHandler):
48+ def end_headers(self):
49+ """Revalidate everything, because no name here promises anything.
50+
51+ A deploy changed what was served and browsers went on showing what
52+ they had -- twice in one afternoon a fix looked broken because the
53+ page was yesterday's. Flutter stamps a fresh `serviceWorkerVersion`
54+ so the new worker does download, but the spec leaves it *waiting*
55+ until every tab on the origin is closed; reloading does not do it,
56+ and the cure was clearing the site's data by hand.
57+
58+ The usual answer -- cache the hashed assets forever, revalidate the
59+ few files with stable names -- does not apply, because nothing in
60+ this bundle is hashed. `main.dart.js`, `frq_core.js`,
61+ `flutter_bootstrap.js` and the rest keep their names and change
62+ their contents on every deploy, so a name here says nothing about
63+ what is behind it.
64+
65+ `no-cache` is not `no-store`: the copy is kept and revalidated, so
66+ an unchanged file costs a conditional GET and comes back 304 with
67+ no body. A round trip per file, against a fix that does not arrive.
68+ """
69+ path = self.path.split("?")[0]
70+ if not path.startswith(UPLOAD):
71+ self.send_header("Cache-Control", "no-cache")
72+ SimpleHTTPRequestHandler.end_headers(self)
73+
4874 def do_POST(self):
4975 if self.path.split("?")[0] != UPLOAD:
5076 self.send_error(404, "Not Found")
@@ -45,6 +45,32 @@ LIMIT = 12 * 1024 * 1024
45 45
46 46
47 class Handler(SimpleHTTPRequestHandler):47 class Handler(SimpleHTTPRequestHandler):
48+ def end_headers(self):
49+ """Revalidate everything, because no name here promises anything.
50+
51+ A deploy changed what was served and browsers went on showing what
52+ they had -- twice in one afternoon a fix looked broken because the
53+ page was yesterday's. Flutter stamps a fresh `serviceWorkerVersion`
54+ so the new worker does download, but the spec leaves it *waiting*
55+ until every tab on the origin is closed; reloading does not do it,
56+ and the cure was clearing the site's data by hand.
57+
58+ The usual answer -- cache the hashed assets forever, revalidate the
59+ few files with stable names -- does not apply, because nothing in
60+ this bundle is hashed. `main.dart.js`, `frq_core.js`,
61+ `flutter_bootstrap.js` and the rest keep their names and change
62+ their contents on every deploy, so a name here says nothing about
63+ what is behind it.
64+
65+ `no-cache` is not `no-store`: the copy is kept and revalidated, so
66+ an unchanged file costs a conditional GET and comes back 304 with
67+ no body. A round trip per file, against a fix that does not arrive.
68+ """
69+ path = self.path.split("?")[0]
70+ if not path.startswith(UPLOAD):
71+ self.send_header("Cache-Control", "no-cache")
72+ SimpleHTTPRequestHandler.end_headers(self)
73+
48 def do_POST(self):74 def do_POST(self):
49 if self.path.split("?")[0] != UPLOAD:75 if self.path.split("?")[0] != UPLOAD:
50 self.send_error(404, "Not Found")76 self.send_error(404, "Not Found")