nandi/frqpublic Fork
⑂ 3c639b8
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

Installable, with a worker that does not hold a deploy back

A browser offers to install a page only when it has a service worker
handling fetches, so a PWA needs one -- and two commits ago I took the
service worker out. Both were right about different things. What made
the old one worth removing was not that it existed but what it did:
Flutter ships an offline-first worker, and a new copy of one waits for
every tab on the origin to close before it takes over, so the app went
on serving the bundle it already had. Twice in one afternoon a working
fix read as broken.

So `--pwa-strategy=none` stays and `flutter/web/frq_sw.js` is
registered in its place. It is the same idea inverted:
`skipWaiting` and `clients.claim`, so a new worker replaces the old
one on the next load rather than the next time every tab is shut; and
network-first, so what is served is what was deployed and the cache is
only what answers when the network does not. Our own GETs alone --
never a POST, never the upload relay, never another origin's bytes,
which come from hosts that did not agree to be kept here.

What it buys is the shell. Opened from a home screen with nothing to
connect to it draws itself and says it is not connected, instead of
the browser's dinosaur. It should not pretend to more: this is a
window onto a live IRC connection.

The manifest wears the app's own colours rather than the Flutter
template's blue, and `orientation` is `any`, the layout having been
taught to handle both.

`tools/webserve.py` speaks HTTP/1.1 now, so a load reuses one
connection instead of opening fifteen. That began as a guess at why
the preview browser would not register a worker, and it was not the
reason -- it refuses whatever it is served, which is why the
registration is unverified here and wants a real phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T16:16:14-07:00 Browse files
3c639b8 parent: d4b430c
modified .modal/web/Dockerfile +3 -3
@@ -32,9 +32,9 @@ COPY . .
3232 # and is not worth an install for one call. Inlined means duplicated, so the
3333 # flags have to be kept in step with that recipe -- `--pwa-strategy=none`
3434 # below is one of them, and the deployment is the place it matters most,
35-# being the one people reach for by URL. See that recipe for what it does
36-# and what it does not: an already-installed worker is replaced by an empty
37-# one rather than removed, and `flutter/web/index.html` removes it. The core is compiled to
35+# being the one people reach for by URL and the only one anybody installs.
36+# It leaves out Flutter's service worker; `flutter/web/frq_sw.js` is
37+# registered instead, and that recipe says why. The core is compiled to
3838 # JavaScript and copied into `flutter/web/` rather than into the output,
3939 # because `flutter build web` copies that directory into the bundle -- which
4040 # is what makes the page's `<script src="frq_core.js">` resolve the same
@@ -32,9 +32,9 @@ COPY . .
32 # and is not worth an install for one call. Inlined means duplicated, so the32 # 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`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,34 # below is one of them, and the deployment is the place it matters most,
35-# being the one people reach for by URL. See that recipe for what it does35+# being the one people reach for by URL and the only one anybody installs.
36-# and what it does not: an already-installed worker is replaced by an empty36+# It leaves out Flutter's service worker; `flutter/web/frq_sw.js` is
37-# one rather than removed, and `flutter/web/index.html` removes it. The core is compiled to37+# registered instead, and that recipe says why. The core is compiled to
38 # JavaScript and copied into `flutter/web/` rather than into the output,38 # JavaScript and copied into `flutter/web/` rather than into the output,
39 # because `flutter build web` copies that directory into the bundle -- which39 # because `flutter build web` copies that directory into the bundle -- which
40 # is what makes the page's `<script src="frq_core.js">` resolve the same40 # is what makes the page's `<script src="frq_core.js">` resolve the same
added flutter/web/frq_sw.js +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+// The service worker, written the other way round from the one we removed.
2+//
3+// A browser will not offer to install a page unless it has a worker with a
4+// fetch handler, so a PWA needs one. Flutter ships such a worker and it is
5+// the wrong one here: it is offline-first, which means a deploy landed only
6+// after a new worker had waited for every tab on the origin to close, and
7+// until then the app went on serving the bundle it had. Twice in one
8+// afternoon a working fix read as broken because of it.
9+//
10+// This one is network-first and takes over at once.
11+//
12+// * `skipWaiting` in install and `clients.claim` in activate: no waiting
13+// room. A new worker replaces the old one on the next load, not on the
14+// next time every tab happens to be closed.
15+// * every GET goes to the network first, and the cache is what answers when
16+// the network does not. So what is served is what was deployed, and the
17+// cache is a fallback rather than the source of truth.
18+//
19+// What that buys on the install side is the shell: opened from a home
20+// screen with no connection it draws itself and says it is not connected,
21+// rather than showing the browser's dinosaur. It cannot do more than that
22+// and should not pretend to -- this is a window onto a live IRC connection,
23+// and without the network there is nothing to look at.
24+const CACHE = "frq-shell-v1";
25+
26+self.addEventListener("install", () => {
27+ self.skipWaiting();
28+});
29+
30+self.addEventListener("activate", (e) => {
31+ e.waitUntil((async () => {
32+ // Anything an older worker left, ours included once the name changes.
33+ for (const k of await caches.keys()) {
34+ if (k !== CACHE) await caches.delete(k);
35+ }
36+ await self.clients.claim();
37+ })());
38+});
39+
40+self.addEventListener("fetch", (e) => {
41+ const req = e.request;
42+ // Only our own GETs. A POST is never replayable, the upload relay least of
43+ // all, and another origin's bytes are not ours to keep -- the avatars and
44+ // pictures come from hosts that never agreed to be cached here.
45+ if (req.method !== "GET") return;
46+ const url = new URL(req.url);
47+ if (url.origin !== self.location.origin) return;
48+ if (url.pathname.startsWith("/api/")) return;
49+
50+ e.respondWith((async () => {
51+ try {
52+ const fresh = await fetch(req);
53+ // Only a real answer is kept. A 404 or a 500 cached is a fault that
54+ // outlives the deploy that caused it.
55+ if (fresh && fresh.ok && fresh.type === "basic") {
56+ const c = await caches.open(CACHE);
57+ c.put(req, fresh.clone());
58+ }
59+ return fresh;
60+ } catch (err) {
61+ const kept = await caches.match(req);
62+ if (kept) return kept;
63+ throw err;
64+ }
65+ })());
66+});
new file mode 100644
@@ -0,0 +1,66 @@
1+// The service worker, written the other way round from the one we removed.
2+//
3+// A browser will not offer to install a page unless it has a worker with a
4+// fetch handler, so a PWA needs one. Flutter ships such a worker and it is
5+// the wrong one here: it is offline-first, which means a deploy landed only
6+// after a new worker had waited for every tab on the origin to close, and
7+// until then the app went on serving the bundle it had. Twice in one
8+// afternoon a working fix read as broken because of it.
9+//
10+// This one is network-first and takes over at once.
11+//
12+// * `skipWaiting` in install and `clients.claim` in activate: no waiting
13+// room. A new worker replaces the old one on the next load, not on the
14+// next time every tab happens to be closed.
15+// * every GET goes to the network first, and the cache is what answers when
16+// the network does not. So what is served is what was deployed, and the
17+// cache is a fallback rather than the source of truth.
18+//
19+// What that buys on the install side is the shell: opened from a home
20+// screen with no connection it draws itself and says it is not connected,
21+// rather than showing the browser's dinosaur. It cannot do more than that
22+// and should not pretend to -- this is a window onto a live IRC connection,
23+// and without the network there is nothing to look at.
24+const CACHE = "frq-shell-v1";
25+
26+self.addEventListener("install", () => {
27+ self.skipWaiting();
28+});
29+
30+self.addEventListener("activate", (e) => {
31+ e.waitUntil((async () => {
32+ // Anything an older worker left, ours included once the name changes.
33+ for (const k of await caches.keys()) {
34+ if (k !== CACHE) await caches.delete(k);
35+ }
36+ await self.clients.claim();
37+ })());
38+});
39+
40+self.addEventListener("fetch", (e) => {
41+ const req = e.request;
42+ // Only our own GETs. A POST is never replayable, the upload relay least of
43+ // all, and another origin's bytes are not ours to keep -- the avatars and
44+ // pictures come from hosts that never agreed to be cached here.
45+ if (req.method !== "GET") return;
46+ const url = new URL(req.url);
47+ if (url.origin !== self.location.origin) return;
48+ if (url.pathname.startsWith("/api/")) return;
49+
50+ e.respondWith((async () => {
51+ try {
52+ const fresh = await fetch(req);
53+ // Only a real answer is kept. A 404 or a 500 cached is a fault that
54+ // outlives the deploy that caused it.
55+ if (fresh && fresh.ok && fresh.type === "basic") {
56+ const c = await caches.open(CACHE);
57+ c.put(req, fresh.clone());
58+ }
59+ return fresh;
60+ } catch (err) {
61+ const kept = await caches.match(req);
62+ if (kept) return kept;
63+ throw err;
64+ }
65+ })());
66+});
modified flutter/web/index.html +19 -19
@@ -23,6 +23,10 @@
2323
2424 <link rel="icon" type="image/png" href="favicon.png"/>
2525
26+ <!-- The browser chrome around an installed window, matching `nim_theme.bg`
27+ rather than the blue the Flutter template shipped. -->
28+ <meta name="theme-color" content="#202833">
29+
2630 <title>frq</title>
2731 <link rel="manifest" href="manifest.json">
2832
@@ -38,30 +42,26 @@
3842 back to loopback or its own origins — and this is neither.
3943 -->
4044 <!--
41- No service worker, and no leftovers of one.
45+ Installable, and ours rather than Flutter's.
46+
47+ A browser offers to install a page only when it has a service worker
48+ with a fetch handler, so a PWA needs one -- and the bundle is still
49+ built `--pwa-strategy=none`, because the worker Flutter ships is the one
50+ that held deploys back: offline-first, and a new copy of it waits for
51+ every tab on the origin to close before it takes over.
4252
43- The bundle is built `--pwa-strategy=none`, so nothing here registers
44- one any more. That settles a new visitor, but not a browser that
45- installed one before: `none` still emits `flutter_service_worker.js` as
46- an empty file, so the old worker is replaced by a no-op rather than
47- removed, and a no-op registration is still a registration.
53+ `frq_sw.js` is network-first and claims its clients immediately. See
54+ that file for what it does and does not promise.
4855
49- This removes it. The app is a window onto a live IRC connection and has
50- no offline worth the name, so nothing is lost with the caches -- and
51- what they cost was a deploy that would not arrive: a new worker waits
52- until every tab on the origin is closed, so reloading went on serving
53- the previous bundle and the cure was clearing the site data by hand.
56+ Registered after load, so it never competes with the bundle for the
57+ connection on a first visit.
5458 -->
5559 <script>
5660 if ("serviceWorker" in navigator) {
57- navigator.serviceWorker.getRegistrations()
58- .then(function (rs) { rs.forEach(function (r) { r.unregister(); }); })
59- .catch(function () { /* nothing to undo */ });
60- }
61- if (window.caches && caches.keys) {
62- caches.keys()
63- .then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); })
64- .catch(function () { /* nothing to undo */ });
61+ window.addEventListener("load", function () {
62+ navigator.serviceWorker.register("frq_sw.js")
63+ .catch(function () { /* a page that will not install still runs */ });
64+ });
6565 }
6666 </script>
6767
@@ -23,6 +23,10 @@
23 23
24 <link rel="icon" type="image/png" href="favicon.png"/>24 <link rel="icon" type="image/png" href="favicon.png"/>
25 25
26+ <!-- The browser chrome around an installed window, matching `nim_theme.bg`
27+ rather than the blue the Flutter template shipped. -->
28+ <meta name="theme-color" content="#202833">
29+
26 <title>frq</title>30 <title>frq</title>
27 <link rel="manifest" href="manifest.json">31 <link rel="manifest" href="manifest.json">
28 32
@@ -38,30 +42,26 @@
38 back to loopback or its own origins — and this is neither.42 back to loopback or its own origins — and this is neither.
39 -->43 -->
40 <!--44 <!--
41- No service worker, and no leftovers of one.45+ Installable, and ours rather than Flutter's.
46+
47+ A browser offers to install a page only when it has a service worker
48+ with a fetch handler, so a PWA needs one -- and the bundle is still
49+ built `--pwa-strategy=none`, because the worker Flutter ships is the one
50+ that held deploys back: offline-first, and a new copy of it waits for
51+ every tab on the origin to close before it takes over.
42 52
43- The bundle is built `--pwa-strategy=none`, so nothing here registers53+ `frq_sw.js` is network-first and claims its clients immediately. See
44- one any more. That settles a new visitor, but not a browser that54+ that file for what it does and does not promise.
45- installed one before: `none` still emits `flutter_service_worker.js` as
46- an empty file, so the old worker is replaced by a no-op rather than
47- removed, and a no-op registration is still a registration.
48 55
49- This removes it. The app is a window onto a live IRC connection and has56+ Registered after load, so it never competes with the bundle for the
50- no offline worth the name, so nothing is lost with the caches -- and57+ connection on a first visit.
51- what they cost was a deploy that would not arrive: a new worker waits
52- until every tab on the origin is closed, so reloading went on serving
53- the previous bundle and the cure was clearing the site data by hand.
54 -->58 -->
55 <script>59 <script>
56 if ("serviceWorker" in navigator) {60 if ("serviceWorker" in navigator) {
57- navigator.serviceWorker.getRegistrations()61+ window.addEventListener("load", function () {
58- .then(function (rs) { rs.forEach(function (r) { r.unregister(); }); })62+ navigator.serviceWorker.register("frq_sw.js")
59- .catch(function () { /* nothing to undo */ });63+ .catch(function () { /* a page that will not install still runs */ });
60- }64+ });
61- if (window.caches && caches.keys) {
62- caches.keys()
63- .then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); })
64- .catch(function () { /* nothing to undo */ });
65 }65 }
66 </script>66 </script>
67 67
modified flutter/web/manifest.json +4 -4
@@ -3,10 +3,10 @@
33 "short_name": "frq",
44 "start_url": ".",
55 "display": "standalone",
6- "background_color": "#0175C2",
7- "theme_color": "#0175C2",
6+ "background_color": "#202833",
7+ "theme_color": "#202833",
88 "description": "freeq client",
9- "orientation": "portrait-primary",
9+ "orientation": "any",
1010 "prefer_related_applications": false,
1111 "icons": [
1212 {
@@ -32,4 +32,4 @@
3232 "purpose": "maskable"
3333 }
3434 ]
35-}
\ No newline at end of file
35+}
@@ -3,10 +3,10 @@
3 "short_name": "frq",3 "short_name": "frq",
4 "start_url": ".",4 "start_url": ".",
5 "display": "standalone",5 "display": "standalone",
6- "background_color": "#0175C2",6+ "background_color": "#202833",
7- "theme_color": "#0175C2",7+ "theme_color": "#202833",
8 "description": "freeq client",8 "description": "freeq client",
9- "orientation": "portrait-primary",9+ "orientation": "any",
10 "prefer_related_applications": false,10 "prefer_related_applications": false,
11 "icons": [11 "icons": [
12 {12 {
@@ -32,4 +32,4 @@
32 "purpose": "maskable"32 "purpose": "maskable"
33 }33 }
34 ]34 ]
35-}
\ No newline at end of file\ No newline at end of file
35+}
modified justfile +8 -16
@@ -194,22 +194,14 @@ _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- # For workers already installed out there it is less immediate than it
207- # looks. `none` still emits `flutter_service_worker.js`, empty -- a 200
208- # of zero bytes rather than a 404 -- so a browser holding the old one
209- # fetches it, sees it changed, and installs the empty worker, which
210- # waits its turn like any other. It has no fetch handler once it takes
211- # over, so nothing is served from a cache after that; and the page
212- # unregisters it outright, which is what `index.html` does.
197+ # `--pwa-strategy=none` leaves out Flutter's service worker, and
198+ # `flutter/web/frq_sw.js` is registered in its place. The page is still
199+ # a PWA -- a browser will not offer to install one without a worker that
200+ # handles fetches -- but not that worker: Flutter's is offline-first, so
201+ # a new copy of it waits for every tab on the origin to close before it
202+ # takes over, and until then the app serves the bundle it already had.
203+ # Twice in one afternoon a working fix read as broken because of it.
204+ # Ours is network-first and claims its clients at once.
213205 #
214206 # The comment lives out here rather than inside the quoted script below,
215207 # which is single-quoted -- an apostrophe in there ends the string, and
@@ -194,22 +194,14 @@ _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 installs197+ # `--pwa-strategy=none` leaves out Flutter's service worker, and
198- # one for offline use and this app has no offline: it is a window onto a198+ # `flutter/web/frq_sw.js` is registered in its place. The page is still
199- # live IRC connection, so a cached copy of it can show nothing anybody199+ # a PWA -- a browser will not offer to install one without a worker that
200- # wants. What the worker did instead was hold a deploy back -- a new one200+ # handles fetches -- but not that worker: Flutter's is offline-first, so
201- # downloads, but the spec leaves it *waiting* until every tab on the201+ # a new copy of it waits for every tab on the origin to close before it
202- # origin is closed, so reloading kept serving the previous bundle and202+ # takes over, and until then the app serves the bundle it already had.
203- # the only cure was clearing the site data by hand. Twice in one203+ # Twice in one afternoon a working fix read as broken because of it.
204- # afternoon a fix looked broken because of it.204+ # Ours is network-first and claims its clients at once.
205- #
206- # For workers already installed out there it is less immediate than it
207- # looks. `none` still emits `flutter_service_worker.js`, empty -- a 200
208- # of zero bytes rather than a 404 -- so a browser holding the old one
209- # fetches it, sees it changed, and installs the empty worker, which
210- # waits its turn like any other. It has no fetch handler once it takes
211- # over, so nothing is served from a cache after that; and the page
212- # unregisters it outright, which is what `index.html` does.
213 #205 #
214 # The comment lives out here rather than inside the quoted script below,206 # The comment lives out here rather than inside the quoted script below,
215 # which is single-quoted -- an apostrophe in there ends the string, and207 # which is single-quoted -- an apostrophe in there ends the string, and
modified tools/webserve.py +12 -0
@@ -45,6 +45,18 @@ LIMIT = 12 * 1024 * 1024
4545
4646
4747 class Handler(SimpleHTTPRequestHandler):
48+ # `http.server` answers HTTP/1.0 and closes the connection after every
49+ # response, which is a new one for each of the fifteen-odd files a load
50+ # asks for. The base class already sends an accurate Content-Length,
51+ # which is what 1.1 needs to be honest about, so keep-alive costs
52+ # nothing here.
53+ #
54+ # It was tried first as a guess at why the preview browser would not
55+ # register a service worker, and it was not the reason -- that browser
56+ # refuses whatever it is served. Kept because connection reuse is worth
57+ # having on its own, not because it fixed anything.
58+ protocol_version = "HTTP/1.1"
59+
4860 def end_headers(self):
4961 """Revalidate everything, because no name here promises anything.
5062
@@ -45,6 +45,18 @@ LIMIT = 12 * 1024 * 1024
45 45
46 46
47 class Handler(SimpleHTTPRequestHandler):47 class Handler(SimpleHTTPRequestHandler):
48+ # `http.server` answers HTTP/1.0 and closes the connection after every
49+ # response, which is a new one for each of the fifteen-odd files a load
50+ # asks for. The base class already sends an accurate Content-Length,
51+ # which is what 1.1 needs to be honest about, so keep-alive costs
52+ # nothing here.
53+ #
54+ # It was tried first as a guess at why the preview browser would not
55+ # register a service worker, and it was not the reason -- that browser
56+ # refuses whatever it is served. Kept because connection reuse is worth
57+ # having on its own, not because it fixed anything.
58+ protocol_version = "HTTP/1.1"
59+
48 def end_headers(self):60 def end_headers(self):
49 """Revalidate everything, because no name here promises anything.61 """Revalidate everything, because no name here promises anything.
50 62