rastrillo / aviso Public

Docs: await the worker's ready, name the content type, scope installation to iOS

Codex's review of the docs: the page snippet reconciled on a
registration that might still be installing, where subscribe()
rejects with InvalidStateError — it now awaits
navigator.serviceWorker.ready. JS() and WorkerJS() are bytes, and
served with Go's default text/plain a browser refuses both a module
and a worker; the wiring step says text/javascript. Installation was
described as a requirement for every phone; it is iOS's, and Android
must not be gated on it. The CSRF sentence overstated a byte-for-byte
Origin comparison that only applies when Sec-Fetch-Site is absent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2b283e16f55ad646ef75a7b7babbe2c05045635d parent a5c2bcd
3 files changed, +40 −29
  • README.md +2 −2
  • SKILL.md +25 −16
  • docs/installable.md +13 −11
diff --git a/README.md b/README.md
index 3eba1de..52a784c 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@ depends on rastrillo, never the reverse.
`SKILL.md` is the authoring doc: how to wire it, send, and write the
browser and worker halves. `docs/installable.md` is the recipe for
-making the app installable, which a phone requires before it will
-deliver push. `example/` is the smallest app that wires every seam.
+making the app installable, which iOS requires before it will deliver
+push. `example/` is the smallest app that wires every seam.
## What it is not
diff --git a/SKILL.md b/SKILL.md
index e00e5fd..d8ede7f 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -22,8 +22,9 @@ service worker's lifecycle are the app's.
3. `svc, err := aviso.New(aviso.Config{DB: writer, PrivateKey: key,
Contact: "mailto:ops@example", Origin: origin})`. One per process;
the send-concurrency bound lives on it. `Origin` is exactly
- `scheme://host[:port]`, no path, no trailing slash — CSRF compares
- the browser's Origin header to it byte for byte.
+ `scheme://host[:port]`, no path, no trailing slash — when a browser
+ sends no Sec-Fetch-Site, CSRF compares its Origin or Referer origin
+ to this string byte for byte.
4. Mount, behind your session middleware:
`GET /aviso/public-key → svc.PublicKey`, `POST /aviso/subscribe →
svc.Subscribe`, `POST /aviso/unsubscribe → svc.Unsubscribe`.
@@ -31,8 +32,11 @@ service worker's lifecycle are the app's.
409 means the endpoint is another account's, or the browser
subscribed under a different server key.
5. Serve `aviso.JS()` as `/static/aviso/push.mjs` and `aviso.WorkerJS()`
- as `/static/aviso/aviso-sw.js`; serve your own `sw.js` at the scope
- it should control with `Cache-Control: no-cache`.
+ as `/static/aviso/aviso-sw.js`, both with
+ `Content-Type: text/javascript` — they are bytes, not handlers, and
+ Go's default `text/plain` makes a browser refuse both a module and
+ a worker script. Serve your own `sw.js` the same way, at the scope
+ it should control, with `Cache-Control: no-cache`.
## Send
@@ -56,19 +60,23 @@ import { enable, reconcile, disable, capabilities } from "/static/aviso/push.mjs
const post = (path) => (b) => fetch(path, { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
const save = post("/aviso/subscribe"), remove = post("/aviso/unsubscribe");
-const registration = await navigator.serviceWorker.register("/sw.js");
+await navigator.serviceWorker.register("/sw.js");
+const registration = await navigator.serviceWorker.ready; // active, not merely registered
const { publicKey } = await (await fetch("/aviso/public-key")).json();
-await reconcile({ registration, publicKey, save }); // every load; never prompts
+await reconcile({ registration, publicKey, save }).catch(console.warn); // every load; never prompts
button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts
```
-`enable` must be called synchronously from the click handler — it
-prompts before its first await, and a prompt after an await is denied.
-It resolves null when denied. `disable({registration, remove})`
-removes the server row first, then the browser subscription; call it
-before sign-out. `save`/`remove` may resolve to nothing; a rejection
-or an `{ok: false}` return counts as failure. `capabilities()` tells
-you whether to show the enable button and the Home Screen coaching.
+`ready` matters: `subscribe()` on a registration whose worker is still
+installing rejects with InvalidStateError. `enable` must be called
+synchronously from the click handler — it prompts before its first
+await, and a prompt after an await is denied. It resolves null when
+denied. `disable({registration, remove})` removes the server row
+first, then the browser subscription; call it before sign-out.
+`save`/`remove` may resolve to nothing; a rejection or an `{ok: false}`
+return counts as failure. `capabilities()` says whether to show the
+enable button (`push`) and, on iOS, the Home Screen coaching
+(`standalone` false).
## Worker (your sw.js)
@@ -105,9 +113,10 @@ account deletion. Re-check entitlement before every `SendTo`.
## Installability
-iOS delivers push only to a Home Screen app, and only after a tap in
-the installed copy. The manifest, head tags and coaching are yours:
-see `docs/installable.md`.
+Android and desktop browsers deliver push to an ordinary website. iOS
+and iPadOS deliver it only to a Home Screen app, and only after a tap
+in the installed copy. The manifest, head tags and iOS coaching are
+yours: see `docs/installable.md`.
## Rulings
diff --git a/docs/installable.md b/docs/installable.md
index 1b9194d..c05ce0c 100644
--- a/docs/installable.md
+++ b/docs/installable.md
@@ -1,10 +1,11 @@
# Making a rastrillo app installable
Aviso does not own any of this, on purpose: a manifest is the app's
-identity, and rastrillo's scaffold is not changed by the addon. But a
-phone will only deliver push to an app it has installed — iOS and
-iPadOS from 16.4, and only from the Home Screen copy — so an app that
-wants push wants this recipe too. It is four things.
+identity, and rastrillo's scaffold is not changed by the addon.
+Android and desktop browsers deliver push to an ordinary website. iOS
+and iPadOS (from 16.4) deliver it only to an app added to the Home
+Screen, and only from that installed copy — so an app that wants push
+on iPhones wants this recipe too. It is four things.
## 1. A manifest
@@ -60,12 +61,13 @@ The rest of the worker is in `SKILL.md`.
## 4. Coaching, keyed on `capabilities()`
`capabilities()` from `push.mjs` reports `standalone`: whether this
-page is running as an installed app. On a phone that is not
-standalone, show the person how to add the app to their Home Screen
-(Share → Add to Home Screen on iOS) and to sign in inside the
-installed copy before pressing the enable button — the browser's
-cookies do not travel into the installed app. Show the enable button
-only when `capabilities().push` is true.
+page is running as an installed app. On iOS and iPadOS, when it is
+not, show the person how to add the app to their Home Screen (Share →
+Add to Home Screen) and to sign in inside the installed copy before
+pressing the enable button — Safari's cookies do not travel into the
+installed app. Do not gate Android or desktop on installation; they
+do not need it. Show the enable button only when `capabilities().push`
+is true, and only once `navigator.serviceWorker.ready` has resolved.
That is the whole recipe. None of it is Web Push; all of it is what
-Web Push needs on a phone.
+Web Push needs on an iPhone, and a good idea everywhere else.