rastrillo / pwa Public

Add optional PWA installation and offline fallback kit

Applications need one worker lifecycle that can compose with aviso without caching signed-in pages or discarding edits during updates. Keep offline data and synchronisation outside this first kit.
Paul Campbell pushed by paul@keymail.dev 321666c27bb448e7ce4e3a39cb0e3c0889b06aba parent a19bb65
24 files changed, +1406 −0
  • .amadan/ci.d/10-go +2 −0
  • .amadan/ci.d/20-js +2 −0
  • .amadan/ci.d/25-example +2 −0
  • .amadan/ci.d/30-browser +2 −0
  • .gitignore +4 −0
  • AGENTS.md +10 −0
  • LICENSE +373 −0
  • Makefile +17 −0
  • README.md +118 −0
  • SKILL.md +54 −0
  • docs/offline.md +31 −0
  • examples/basic/go.mod +32 −0
  • examples/basic/go.sum +103 −0
  • examples/basic/main.go +87 −0
  • go.mod +3 −0
  • js/client.mjs +32 −0
  • js/worker.js +48 −0
  • package-lock.json +60 −0
  • package.json +6 −0
  • pwa.go +126 −0
  • pwa_test.go +73 −0
  • test/browser.mjs +114 −0
  • test/client.test.mjs +36 −0
  • test/worker.test.mjs +71 −0

CI cancelled β€” superseded by a newer push — run details

diff --git a/.amadan/ci.d/10-go b/.amadan/ci.d/10-go
new file mode 100755
index 0000000..b68b362
--- /dev/null
+++ b/.amadan/ci.d/10-go
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make go
diff --git a/.amadan/ci.d/20-js b/.amadan/ci.d/20-js
new file mode 100755
index 0000000..cfdd770
--- /dev/null
+++ b/.amadan/ci.d/20-js
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make js
diff --git a/.amadan/ci.d/25-example b/.amadan/ci.d/25-example
new file mode 100755
index 0000000..7f1909d
--- /dev/null
+++ b/.amadan/ci.d/25-example
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make example
diff --git a/.amadan/ci.d/30-browser b/.amadan/ci.d/30-browser
new file mode 100755
index 0000000..ac9cd5a
--- /dev/null
+++ b/.amadan/ci.d/30-browser
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make browser
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8551461
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.claude/
+.build/
+node_modules/
+examples/basic/pwa-basic
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..38d56fc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,10 @@
+# Working here
+
+Work and review live at https://amadan.net/rastrillo/pwa. Use the amadan
+agent skill and your agent account. Take a worktree and branch, describe it,
+keep tasks current, and land with `amadan branch merge`. Never squash.
+
+Run `make ci` before pushing. `.amadan/ci.d` calls the same Make targets.
+Examples are separate Go modules and must be checked from their directories.
+Keep worker updates explicit and private data out of caches. Review English
+copy before translating it. Comments explain the failure prevented.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d0a1fa1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..23738b7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+export GOFLAGS = -mod=mod
+export CGO_ENABLED = 0
+.PHONY: ci go js example browser
+ci: go js example browser
+go:
+ @test -z "$$(gofmt -l .)"
+ go build ./...
+ go vet ./...
+ go test ./... -count=1
+js:
+ node --test test/*.test.mjs
+example:
+ @mkdir -p .build
+ cd examples/basic && go build -o ../../.build/basic . && go vet ./... && go test ./... -count=1
+browser:
+ npm ci --ignore-scripts
+ node test/browser.mjs
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..042183d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,118 @@
+# Rastrillo PWA
+
+Add an installable app manifest, a public offline page and controlled worker
+updates to a web app. This optional module has no runtime dependencies.
+Web Push composes through [aviso](https://amadan.net/rastrillo/aviso).
+
+The kit does not store pages, API responses, messages, keys or pending writes.
+Offline navigation shows a public fallback. Offline reading and editing need
+an application data model and synchronisation contract; see
+[docs/offline.md](docs/offline.md).
+
+## Try it
+
+From this checkout:
+
+```sh
+cd examples/basic
+go run .
+```
+
+Open `http://127.0.0.1:8080`, reload once after the worker has installed,
+then disconnect and reload. The worker returns an offline page with a retry
+link. Connect again and retry to return to the app. The sample serves valid
+PNG icons as placeholders; replace them with your app's icons.
+
+The sample imports aviso's worker helper and wires notification handlers.
+It does not enrol subscriptions or send push messages. Follow aviso's own
+skill to provision its server and browser enrolment.
+
+## Add it to an app
+
+Read [SKILL.md](SKILL.md). Install a reviewed version of
+`amadan.net/rastrillo/pwa`. Create `pwa.Manifest` with a permanent ID,
+name, start URL, scope ending in `/`, and your icons. Call `.Handler()` at
+boot and handle its error. Mount the returned handler at
+`/manifest.webmanifest`. Mount assets with:
+
+```go
+mux.Handle("/pwa/", http.StripPrefix("/pwa", pwa.Assets()))
+```
+
+Add the manifest link, theme colour and Apple touch icon to the page head:
+
+```html
+<link rel="manifest" href="/manifest.webmanifest">
+<meta name="theme-color" content="#234d45">
+<link rel="apple-touch-icon" href="/static/icon-180.png">
+```
+
+Serve your own `/sw.js` with `Content-Type: text/javascript` and
+`Cache-Control: no-cache`:
+
+```js
+importScripts("/pwa/worker.js");
+RastrilloPWA.install();
+```
+
+Then register it from your page's JavaScript:
+
+```js
+import { register } from "/pwa/client.mjs";
+await register({onUpdate: () => { updateNotice.hidden = false; }});
+```
+
+Define `updateNotice` in the app. Suggested text: β€œAn update is ready. Save
+your work in all tabs, then close and reopen the app.” The helper neither
+reloads pages nor automatically activates a waiting worker. Browser support
+is detected; registration resolves to `null` without service workers.
+
+`activateUpdate(registration)` explicitly asks a waiting worker to activate
+and returns whether there was one. Activation affects all tabs in its scope.
+Use it only when the app has resolved unsaved work across those tabs; handle
+`controllerchange` in the app if a reload is appropriate. Closing all tabs
+allows normal browser activation without this helper.
+
+## Worker contract
+
+`RastrilloPWA.install({offlineHTML})` attaches navigation and update-message
+handlers once. `offlineHTML` is optional, trusted build-time public HTML.
+The default is a self-contained English page. Provide an app-owned public
+translation if needed; neither account details nor keys belong in it.
+
+Only same-origin, in-scope GET navigations are intercepted. A network
+failure returns the fallback with status 503 and `Cache-Control: no-store`.
+HTTP errors remain unchanged. API calls and mutations are untouched. The
+fallback permits inline styles but no scripts, forms or external resources.
+
+The worker and imported scripts are persisted by the browser's worker
+installation; the kit never writes Cache Storage or IndexedDB. With no
+`clients.claim`, the first page stays uncontrolled until its next navigation.
+Removing the worker registration removes the fallback capability.
+
+Use one worker registration per app scope. To add push, import aviso's
+helper into this same `sw.js`, attach its push/click/subscription-change
+handlers, and pass the same registration to its browser module. Read the
+version-pinned aviso skill for its authentication and key-rotation contracts.
+Keep notification payloads and encryption in the app.
+
+Production needs HTTPS. Installation UI differs by browser; on iOS guide
+the person to add the app to the Home Screen and sign in inside that copy
+before enabling push. Registration alone does not prompt installation or
+grant notification permission.
+
+## Validation
+
+`make ci` runs Go and JavaScript tests and a real Chromium/WebKit browser
+drive. Install the matching Playwright browsers first with
+`npx playwright install chromium webkit` after `npm ci`. Missing browsers
+fail the gate. The nested example is built and tested by the gate too.
+
+The browser drive covers navigation failures, preservation of HTTP errors,
+API failure behaviour, empty caches and updates across two edited tabs.
+Chromium uses its offline switch; WebKit uses a dropped network connection
+because its automation switch can abort before calling the worker. Device
+installation and real iOS push delivery remain manual checks; this gate
+does not claim them.
+
+MPL-2.0; see [LICENSE](LICENSE).
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..d47c29a
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: rastrillo-pwa
+description: Add installation, a public offline fallback and service-worker update handling to a Rastrillo web app, with optional aviso Web Push composition.
+---
+
+# Rastrillo PWA
+
+Module and source: `amadan.net/rastrillo/pwa`. Read this skill from the
+version the app imports. This kit adds installability and an offline
+fallback, not offline data or writes. For the latter, read
+[docs/offline.md](docs/offline.md) before choosing an app data model.
+
+Use `examples/basic/main.go` as the complete wiring reference. It is a
+separate Go module, so read it from the repository, not a module-cache zip.
+
+- Create `pwa.Manifest` with stable `ID`, `Name`, `StartURL`, `Scope` and
+ icons. Paths are root-relative, scope ends in `/`, and start URL stays
+ within scope. Call `.Handler()` at boot and check the error. Supply actual
+ 192x192 and 512x512 PNGs, plus a 180px Apple touch icon. Branding is app-owned.
+- Mount the manifest at `/manifest.webmanifest`. Mount `pwa.Assets()` using
+ `http.StripPrefix("/pwa", ...)` at `/pwa/`. Assets have JavaScript MIME
+ types and `no-cache`; add manifest, theme-colour and Apple icon head tags.
+- Serve app-owned `/sw.js` at its intended scope with JavaScript MIME and
+ `Cache-Control: no-cache`. It imports `/pwa/worker.js` and calls
+ `RastrilloPWA.install()` once. For `/app/` scope, use `/app/sw.js` and
+ matching manifest scope/start paths; asset helpers may live elsewhere.
+- Import `register` from `/pwa/client.mjs`. It returns a registration or
+ `null` when unsupported. It reports waiting updates through `onUpdate`;
+ it never reloads or asks for permission. Without `clients.claim`, the
+ first page becomes controlled on its next navigation. Wait for
+ `navigator.serviceWorker.ready` before passing the registration to push.
+
+Update activation is app policy. Prefer asking people to save and close all
+tabs. `activateUpdate(registration)` explicitly activates a waiting worker
+and affects every tab in its scope. Call only after resolving unsaved work
+across them. The app owns any `controllerchange` reload.
+
+The worker intercepts only in-scope GET navigations. Network failures get a
+503 public offline page; 401/403/500 responses remain unchanged. API calls
+and writes stay on the network. No Cache Storage or IndexedDB writes.
+`install({offlineHTML})` accepts a trusted, public, self-contained document
+for custom copy/translations. Its CSP permits inline style, no scripts or
+external assets. Never interpolate a signed-in person's data into it.
+
+For push, load the pinned `amadan.net/rastrillo/aviso` skill and compose its
+worker handlers into the same `/sw.js`. Keep one registration per scope;
+do not register a second push worker over it. Aviso owns subscriptions and
+transport. The app owns notification policy, payloads and encryption.
+Push permission must be requested from a user gesture. On iOS, guide the
+person to install and sign in inside the Home Screen copy first.
+
+Validate with `make ci` and the app's own gate. The module gate includes its
+nested example and Chromium/WebKit worker tests; physical-device install
+and push checks are separate. There is no native adapter in this module.
diff --git a/docs/offline.md b/docs/offline.md
new file mode 100644
index 0000000..59b95fb
--- /dev/null
+++ b/docs/offline.md
@@ -0,0 +1,31 @@
+# Offline data is a separate capability
+
+The PWA kit lets an installed worker answer failed navigations with a public
+offline page. It does not make server-rendered screens usable offline.
+
+An offline app needs a local model, durable pending operations, and a server
+protocol that can reconcile them. Extract those mechanisms from an existing
+application only after a second, different application can adopt them.
+Eleven and Ocho share ancestry, so their duplicate code alone does not prove
+a general data or conflict model.
+
+Before calling an offline kit reusable, prove these behaviours:
+
+- Pending operations survive app/worker termination and restart.
+- Retrying after a lost response cannot duplicate the server operation.
+- Account switching and sign-out cannot reveal or submit another account's data.
+- Local schema upgrades and server protocol changes preserve queued work.
+- Conflicts, deletes and revoked access have explicit, tested outcomes.
+- Synchronisation resumes when the app opens; background execution is optional.
+
+Encrypted offline storage adds a separate key lifecycle. Define what can be
+read while locked, how keys are recovered or revoked, how account removal
+clears local material, and what notifications can reveal. Ciphertext beside
+an accessible decryption key is not equivalent to a locked vault.
+
+Reuse Rastrillo's crypto/keyring contracts where compatible and test bytes
+across implementations. Do not copy Eleven's thread-key or message policy
+into a general storage package. Keep browser and native storage adapters
+separate even when their protocol and test vectors are shared.
+
+No offline sync engine or encrypted vault is shipped by this first kit.
diff --git a/examples/basic/go.mod b/examples/basic/go.mod
new file mode 100644
index 0000000..87bf508
--- /dev/null
+++ b/examples/basic/go.mod
@@ -0,0 +1,32 @@
+module example.com/pwa-basic
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/aviso v0.1.0
+ amadan.net/rastrillo/pwa v0.0.0
+)
+
+require (
+ amadan.net/rastrillo/rastrillo v0.26.0 // indirect
+ github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ gorm.io/gorm v1.31.2 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
+
+replace amadan.net/rastrillo/pwa => ../..
diff --git a/examples/basic/go.sum b/examples/basic/go.sum
new file mode 100644
index 0000000..ae2d1c3
--- /dev/null
+++ b/examples/basic/go.sum
@@ -0,0 +1,103 @@
+amadan.net/rastrillo/aviso v0.1.0 h1:Y/t35DdYksSzSpJXCmcY1hZQ6ceD05GNy4Hj+AevIhc=
+amadan.net/rastrillo/aviso v0.1.0/go.mod h1:LG34BrOwGr+09ukG9EuK/nGB793iGntmkBI0fiWNffI=
+amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
+amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
+github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
diff --git a/examples/basic/main.go b/examples/basic/main.go
new file mode 100644
index 0000000..5aa7120
--- /dev/null
+++ b/examples/basic/main.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+ "net"
+ "net/http"
+ "strconv"
+
+ "amadan.net/rastrillo/aviso"
+ "amadan.net/rastrillo/pwa"
+)
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:8080", "listen address")
+ flag.Parse()
+ mux := http.NewServeMux()
+ manifest, err := (pwa.Manifest{ID: "/", Name: "PWA Example", StartURL: "/", Scope: "/",
+ ThemeColor: "#234d45", BackgroundColor: "#ffffff",
+ Icons: []pwa.Icon{
+ {Src: "/icon/192", Sizes: "192x192", Type: "image/png"},
+ {Src: "/icon/512", Sizes: "512x512", Type: "image/png"},
+ },
+ }).Handler()
+ if err != nil {
+ log.Fatal(err)
+ }
+ mux.Handle("/manifest.webmanifest", manifest)
+ mux.Handle("/pwa/", http.StripPrefix("/pwa", pwa.Assets()))
+ mux.HandleFunc("GET /aviso-sw.js", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(aviso.WorkerJS())
+ })
+ mux.HandleFunc("GET /sw.js", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Header().Set("Cache-Control", "no-cache")
+ fmt.Fprint(w, `importScripts("/pwa/worker.js", "/aviso-sw.js");
+RastrilloPWA.install();
+self.addEventListener("push", e => e.waitUntil(AvisoSW.handlePush(e, {
+ fallback: () => ({title: "New activity", options: {data: {url: "/"}}})
+})));
+self.addEventListener("notificationclick", e => e.waitUntil(AvisoSW.handleClick(e, {fallbackURL: "/"})));
+`)
+ })
+ mux.HandleFunc("GET /icon/{size}", func(w http.ResponseWriter, r *http.Request) {
+ size, _ := strconv.Atoi(r.PathValue("size"))
+ if size != 180 && size != 192 && size != 512 {
+ http.NotFound(w, r)
+ return
+ }
+ im := image.NewRGBA(image.Rect(0, 0, size, size))
+ for y := 0; y < size; y++ {
+ for x := 0; x < size; x++ {
+ im.Set(x, y, color.RGBA{35, 77, 69, 255})
+ }
+ }
+ w.Header().Set("Content-Type", "image/png")
+ _ = png.Encode(w, im)
+ })
+ mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ fmt.Fprint(w, `<!doctype html><html lang="en"><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/icon/180">
+<meta name="theme-color" content="#234d45"><title>PWA Example</title>
+<main><h1>PWA Example</h1><p>Add this app to your Home Screen or use your browser's install option.</p>
+<p>Open it once online. Then disconnect and reload to see the offline page.</p>
+<label>Try an unsaved edit <input id="draft"></label>
+<p id="update" hidden>An update is ready. Save your work in all tabs, then close and reopen the app.</p>
+<script type="module">
+import {register} from "/pwa/client.mjs";
+register({onUpdate: () => {document.querySelector("#update").hidden = false;}}).catch(console.error);
+</script></main></html>`)
+ })
+ listener, err := net.Listen("tcp", *addr)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("http://%s\n", listener.Addr())
+ log.Fatal(http.Serve(listener, mux))
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f8ff3ba
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module amadan.net/rastrillo/pwa
+
+go 1.25.0
diff --git a/js/client.mjs b/js/client.mjs
new file mode 100644
index 0000000..e61e525
--- /dev/null
+++ b/js/client.mjs
@@ -0,0 +1,32 @@
+// No reload or permission prompt is implicit: the app knows about unsaved work
+// and whether the person actually asked to enable notifications.
+export async function register({ url = "/sw.js", scope, onUpdate = () => {} } = {}) {
+ if (!globalThis.navigator?.serviceWorker) return null;
+ const registration = await navigator.serviceWorker.register(url, {
+ ...(scope ? { scope } : {}),
+ updateViaCache: "none",
+ });
+ const seen = new WeakSet();
+ const notify = () => {
+ const worker = registration.waiting;
+ if (worker && !seen.has(worker)) {
+ seen.add(worker);
+ onUpdate(registration);
+ }
+ };
+ const watch = () => {
+ registration.installing?.addEventListener("statechange", notify);
+ notify();
+ };
+ registration.addEventListener("updatefound", watch);
+ watch();
+ return registration;
+}
+
+// Activation affects the registration shared by all tabs. Call only after the
+// app has resolved unsaved work across those tabs; this helper never reloads.
+export function activateUpdate(registration) {
+ if (!registration?.waiting) return false;
+ registration.waiting.postMessage({ type: "rastrillo:pwa:activate" });
+ return true;
+}
diff --git a/js/worker.js b/js/worker.js
new file mode 100644
index 0000000..4ef93c6
--- /dev/null
+++ b/js/worker.js
@@ -0,0 +1,48 @@
+// The app calls install once in its classic worker, alongside aviso's handlers.
+// Keeping the fallback in the worker avoids caching a login redirect or a page
+// rendered for the account that happened to install it.
+(function (root) {
+ "use strict";
+ const fallback = `<!doctype html><html lang="en"><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>You're offline</title><style>body{font:1.1rem system-ui;margin:4rem auto;padding:0 1.5rem;max-width:32rem;line-height:1.6}a{color:inherit}</style>
+<main><h1>You're offline</h1><p>Connect to the internet, then try again.</p><a href="">Try again</a></main></html>`;
+ let installed = false;
+
+ function install({ offlineHTML = fallback } = {}) {
+ if (installed) throw new Error("pwa: install may only be called once per worker");
+ if (typeof offlineHTML !== "string" || !offlineHTML.trim()) {
+ throw new Error("pwa: offlineHTML must be a non-empty public HTML document");
+ }
+ installed = true;
+ const scope = new URL(root.registration.scope);
+ function inScope(raw) {
+ const url = new URL(raw);
+ return url.origin === scope.origin && url.pathname.startsWith(scope.pathname);
+ }
+
+ root.addEventListener("fetch", (event) => {
+ const request = event.request;
+ if (request.method !== "GET" || request.mode !== "navigate" || !inScope(request.url)) return;
+ // Never turn HTTP errors into offline successes, and never persist the
+ // network response: it may contain a signed-in person's data.
+ event.respondWith(root.fetch(request, { cache: "no-store" }).catch(() => new Response(offlineHTML, {
+ status: 503,
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store",
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+ },
+ })));
+ });
+
+ root.addEventListener("message", (event) => {
+ if (event.data?.type !== "rastrillo:pwa:activate" || !event.source?.url || !inScope(event.source.url)) return;
+ event.waitUntil(root.skipWaiting());
+ });
+ // No unconditional skipWaiting or clients.claim: an older tab may still
+ // have unsaved edits. The app chooses when all tabs can accept an update.
+ }
+
+ root.RastrilloPWA = { install };
+})(self);
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..fd76c75
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,60 @@
+{
+ "name": "rastrillo-pwa-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rastrillo-pwa-tests",
+ "devDependencies": {
+ "playwright": "1.62.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..693c6ec
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "rastrillo-pwa-tests",
+ "private": true,
+ "type": "module",
+ "devDependencies": { "playwright": "1.62.0" }
+}
diff --git a/pwa.go b/pwa.go
new file mode 100644
index 0000000..95edb9d
--- /dev/null
+++ b/pwa.go
@@ -0,0 +1,126 @@
+// Package pwa supplies installability and offline-fallback assets for a web app.
+// The app owns its manifest identity, worker entrypoint and update UI.
+package pwa
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+//go:embed js/client.mjs js/worker.js
+var assets embed.FS
+
+// Assets serves client.mjs and worker.js. Mount with http.StripPrefix at
+// an app-owned path; keep sw.js at the scope it should control.
+func Assets() http.Handler {
+ f, _ := fs.Sub(assets, "js")
+ files := http.FileServer(http.FS(f))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if r.URL.Path != "/client.mjs" && r.URL.Path != "/worker.js" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ files.ServeHTTP(w, r)
+ })
+}
+
+type Icon struct {
+ Src string `json:"src"`
+ Sizes string `json:"sizes"`
+ Type string `json:"type"`
+ Purpose string `json:"purpose,omitempty"`
+}
+
+// Manifest is application identity. Keep ID stable across start URL changes,
+// or browsers may treat an update as a different installed app.
+type Manifest struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ShortName string `json:"short_name,omitempty"`
+ StartURL string `json:"start_url"`
+ Scope string `json:"scope"`
+ Display string `json:"display"`
+ ThemeColor string `json:"theme_color,omitempty"`
+ BackgroundColor string `json:"background_color,omitempty"`
+ Icons []Icon `json:"icons"`
+}
+
+// Handler validates the manifest at boot so an invalid scope cannot silently
+// turn an installed app's first launch into a browser navigation.
+func (m Manifest) Handler() (http.Handler, error) {
+ if strings.TrimSpace(m.Name) == "" {
+ return nil, fmt.Errorf("pwa: name is required")
+ }
+ if !localPath(m.ID) || !localPath(m.StartURL) || !localPath(m.Scope) ||
+ !strings.HasSuffix(m.Scope, "/") || strings.ContainsAny(m.Scope, "?#") ||
+ !strings.HasPrefix(m.StartURL, m.Scope) {
+ return nil, fmt.Errorf("pwa: use root-relative paths and a start URL within a scope ending in /")
+ }
+ if m.Display == "" {
+ m.Display = "standalone"
+ }
+ switch m.Display {
+ case "standalone", "minimal-ui", "fullscreen", "browser":
+ default:
+ return nil, fmt.Errorf("pwa: unsupported display mode")
+ }
+ if len(m.Icons) == 0 {
+ return nil, fmt.Errorf("pwa: supply app icons, including 192x192 and 512x512 PNGs")
+ }
+ for _, icon := range m.Icons {
+ if !localPath(icon.Src) || icon.Sizes == "" || icon.Type == "" {
+ return nil, fmt.Errorf("pwa: icons need a root-relative src, sizes and type")
+ }
+ }
+ body, err := json.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/manifest+json")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Method != http.MethodHead {
+ _, _ = w.Write(body)
+ }
+ }), nil
+}
+
+func localPath(raw string) bool {
+ if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\r\n\t#") {
+ return false
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host != "" || u.Scheme != "" || u.Opaque != "" {
+ return false
+ }
+ // Browsers normalize escaped dots and backslashes before scope matching.
+ // Refuse ambiguous forms so Go and the browser cannot disagree about scope.
+ if strings.Contains(u.Path, "\\") || strings.Contains(u.Path, "//") {
+ return false
+ }
+ for _, part := range strings.Split(u.Path, "/") {
+ if part == "." || part == ".." {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pwa_test.go b/pwa_test.go
new file mode 100644
index 0000000..69e668f
--- /dev/null
+++ b/pwa_test.go
@@ -0,0 +1,73 @@
+package pwa
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func exampleManifest() Manifest {
+ return Manifest{ID: "/app/", Name: "Example", StartURL: "/app/inbox", Scope: "/app/",
+ Icons: []Icon{{Src: "/icon.png", Sizes: "192x192", Type: "image/png"}}}
+}
+
+func TestManifestIdentityAndHeaders(t *testing.T) {
+ m := exampleManifest()
+ m.Name = `Example "quoted" <app>`
+ h, err := m.Handler()
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "/manifest.webmanifest", nil))
+ var got Manifest
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.ID != m.ID || got.Name != m.Name || got.Display != "standalone" {
+ t.Fatalf("identity changed: %+v", got)
+ }
+ if w.Header().Get("Content-Type") != "application/manifest+json" || w.Header().Get("Cache-Control") != "no-cache" {
+ t.Fatal(w.Header())
+ }
+ for _, method := range []string{"HEAD", "POST"} {
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(method, "/manifest.webmanifest", nil))
+ if method == "HEAD" && w.Body.Len() != 0 {
+ t.Fatal("HEAD returned a body")
+ }
+ if method == "POST" && w.Code != http.StatusMethodNotAllowed {
+ t.Fatal("manifest accepted a mutation")
+ }
+ }
+}
+
+func TestManifestRejectsScopeEscapes(t *testing.T) {
+ for _, start := range []string{"/other/", "//evil.example/", "/app/../outside", "/app/%2e%2e/outside", "/app/%5c../outside", "/app/./inbox", "/app/\n/", "https://example.com/app/"} {
+ t.Run(start, func(t *testing.T) {
+ m := exampleManifest()
+ m.StartURL = start
+ if _, err := m.Handler(); err == nil {
+ t.Fatalf("accepted ambiguous or out-of-scope start URL %q", start)
+ }
+ })
+ }
+}
+
+func TestAssetsAreScriptsAndNeverDirectoryListings(t *testing.T) {
+ h := http.StripPrefix("/pwa", Assets())
+ for _, path := range []string{"/pwa/client.mjs", "/pwa/worker.js"} {
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
+ if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/javascript") || w.Body.Len() == 0 {
+ t.Fatalf("%s: %d %v", path, w.Code, w.Header())
+ }
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "/pwa/", nil))
+ if w.Code != 404 {
+ t.Fatal("assets exposed a directory listing")
+ }
+}
diff --git a/test/browser.mjs b/test/browser.mjs
new file mode 100644
index 0000000..170db5d
--- /dev/null
+++ b/test/browser.mjs
@@ -0,0 +1,114 @@
+import { chromium, webkit } from "playwright";
+import { spawn, execFileSync } from "node:child_process";
+import { createServer } from "node:http";
+import { mkdirSync } from "node:fs";
+import { once } from "node:events";
+import assert from "node:assert/strict";
+
+mkdirSync(".build", { recursive: true });
+execFileSync("go", ["build", "-o", "../../.build/example", "."], { cwd: "examples/basic", stdio: "inherit" });
+const app = spawn(".build/example", ["-addr", "127.0.0.1:0"], { stdio: ["ignore", "pipe", "inherit"] });
+let proxy;
+let browser;
+try {
+ const upstream = await new Promise((resolve, reject) => {
+ let output = "";
+ app.stdout.on("data", chunk => {
+ output += chunk;
+ if (output.includes("\n")) resolve(output.trim());
+ });
+ app.on("error", reject);
+ app.on("exit", code => reject(new Error(`example exited: ${code}`)));
+ });
+ let revision = 1;
+ let disconnected = false;
+ // Change only worker bytes between visits: this reproduces a deployment
+ // without adding test-only routes or switches to the example application.
+ proxy = createServer(async (req, res) => {
+ try {
+ if (disconnected) { req.socket.destroy(); return; }
+ if (req.url === "/server-error") { res.writeHead(500); res.end("Server error"); return; }
+ if (req.url === "/private") { res.writeHead(401); res.end("Sign in"); return; }
+ const response = await fetch(upstream + req.url);
+ const headers = Object.fromEntries(response.headers);
+ delete headers["content-length"];
+ delete headers["transfer-encoding"];
+ res.writeHead(response.status, headers);
+ const body = Buffer.from(await response.arrayBuffer());
+ res.end(req.url === "/sw.js" ? `${body}\n// revision ${revision}\n` : body);
+ } catch (error) {
+ res.writeHead(502); res.end(String(error));
+ }
+ }).listen(0, "127.0.0.1");
+ await once(proxy, "listening");
+ const origin = `http://127.0.0.1:${proxy.address().port}`;
+
+ for (const [name, engine] of [["chromium", chromium], ["webkit", webkit]]) {
+ browser = await engine.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const errors = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.goto(origin);
+ await page.evaluate(async () => { await navigator.serviceWorker.ready; });
+ await page.reload();
+ await page.waitForFunction(() => navigator.serviceWorker.controller);
+ const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
+ assert.equal(manifest.id, "/");
+ assert.deepEqual(manifest.icons.map(icon => icon.sizes), ["192x192", "512x512"]);
+
+ const response = await page.goto(origin + "/private");
+ assert.equal(response.status(), 401);
+ assert.equal(await page.locator("body").innerText(), "Sign in");
+ assert.equal((await page.goto(origin + "/server-error")).status(), 500);
+ // WebKit's automation offline switch can abort navigation before invoking
+ // a controlling worker. Drop the connection at the server instead; the
+ // browser still has to handle a real failed network request in its worker.
+ disconnected = true;
+ if (name === "chromium") await context.setOffline(true);
+ const offline = await page.goto(origin + "/private");
+ assert.equal(offline.status(), 503);
+ assert.equal(await page.locator("h1").innerText(), "You're offline");
+ assert.equal(await page.evaluate(async () => (await caches.keys()).length), 0);
+ assert.equal(await page.evaluate(async () => {
+ try { await fetch("/api/data"); return "unexpected response"; }
+ catch { return "network failure"; }
+ }), "network failure", "API failures must not become HTML fallback responses");
+
+ disconnected = false;
+ if (name === "chromium") await context.setOffline(false);
+ await page.goto(origin);
+ await page.locator("#draft").fill("keep my draft");
+ const other = await context.newPage();
+ await other.goto(origin);
+ await other.locator("#draft").fill("another tab's draft");
+ revision++;
+ await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration()).update(); });
+ await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration()).waiting);
+ assert.equal(await page.locator("#draft").inputValue(), "keep my draft");
+ assert.equal(await other.locator("#draft").inputValue(), "another tab's draft");
+ await page.waitForFunction(() => !document.querySelector("#update").hidden);
+ await page.evaluate(async () => {
+ const { activateUpdate } = await import("/pwa/client.mjs");
+ const changed = new Promise(resolve => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }));
+ if (!activateUpdate(await navigator.serviceWorker.getRegistration())) throw new Error("missing update");
+ await changed;
+ });
+ assert.equal(await page.locator("#draft").inputValue(), "keep my draft", "activation must not reload the page");
+ assert.equal(await other.locator("#draft").inputValue(), "another tab's draft");
+ if (name === "chromium") {
+ const workers = context.serviceWorkers();
+ assert.ok(workers.length > 0);
+ assert.equal(await workers.at(-1).evaluate(() => typeof AvisoSW.handlePush), "function", "aviso shares the installed worker");
+ }
+ assert.deepEqual(errors, []);
+ await browser.close();
+ browser = null;
+ console.log(`${name}: offline navigation, network errors, no data cache, two-tab updates and worker composition passed`);
+ }
+} finally {
+ await browser?.close();
+ proxy?.close();
+ proxy?.closeAllConnections();
+ app.kill("SIGTERM");
+}
diff --git a/test/client.test.mjs b/test/client.test.mjs
new file mode 100644
index 0000000..20c78bb
--- /dev/null
+++ b/test/client.test.mjs
@@ -0,0 +1,36 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { register, activateUpdate } from "../js/client.mjs";
+
+test("unsupported browsers retain the ordinary web app", async () => {
+ assert.equal(await register(), null);
+});
+
+test("waiting updates are announced without activating or reloading", async () => {
+ const previous = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+ const events = {};
+ const messages = [];
+ const waiting = { postMessage: (message) => messages.push(message) };
+ const registration = { waiting, addEventListener: (name, fn) => { events[name] = fn; } };
+ Object.defineProperty(globalThis, "navigator", { configurable: true, value: {
+ serviceWorker: { register: async (url, options) => {
+ assert.equal(url, "/app/sw.js");
+ assert.equal(options.updateViaCache, "none");
+ return registration;
+ } },
+ } });
+ try {
+ let updates = 0;
+ assert.equal(await register({ url: "/app/sw.js", onUpdate: () => { updates++; } }), registration);
+ assert.equal(updates, 1);
+ events.updatefound();
+ assert.equal(updates, 1, "do not announce the same waiting worker twice");
+ assert.equal(messages.length, 0);
+ assert.equal(activateUpdate(registration), true);
+ assert.deepEqual(messages, [{ type: "rastrillo:pwa:activate" }]);
+ registration.waiting = null;
+ assert.equal(activateUpdate(registration), false);
+ } finally {
+ Object.defineProperty(globalThis, "navigator", previous);
+ }
+});
diff --git a/test/worker.test.mjs b/test/worker.test.mjs
new file mode 100644
index 0000000..af01ae0
--- /dev/null
+++ b/test/worker.test.mjs
@@ -0,0 +1,71 @@
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const source = readFileSync(new URL("../js/worker.js", import.meta.url), "utf8");
+function worker(fetch = async () => { throw new Error("offline"); }) {
+ const listeners = {};
+ let activations = 0;
+ const self = {
+ registration: { scope: "https://app.example/app/" }, fetch,
+ addEventListener: (name, fn) => { listeners[name] = fn; },
+ skipWaiting: async () => { activations++; },
+ };
+ vm.runInNewContext(source, { self, URL, Response });
+ self.RastrilloPWA.install();
+ return { listeners, self, activations: () => activations };
+}
+function navigate(w, request = {}) {
+ let promise;
+ w.listeners.fetch({
+ request: { url: "https://app.example/app/inbox", method: "GET", mode: "navigate", ...request },
+ respondWith: (p) => { promise = p; },
+ });
+ return promise;
+}
+
+test("offline navigation returns a public, non-cacheable fallback", async () => {
+ const response = await navigate(worker());
+ assert.equal(response.status, 503);
+ assert.equal(response.headers.get("cache-control"), "no-store");
+ assert.match(await response.text(), /Connect to the internet/);
+});
+
+test("HTTP authentication and server errors remain visible", async () => {
+ for (const status of [401, 403, 500]) {
+ const w = worker(async (_request, options) => {
+ assert.equal(options.cache, "no-store");
+ return new Response("server response", { status });
+ });
+ const response = await navigate(w);
+ assert.equal(response.status, status);
+ assert.equal(await response.text(), "server response");
+ }
+});
+
+test("API calls, writes, external and out-of-scope navigations are untouched", () => {
+ const w = worker(() => { assert.fail("must not fetch"); });
+ for (const request of [
+ { mode: "cors" }, { method: "POST" }, { url: "https://other.example/app/" },
+ { url: "https://app.example/another-app/" },
+ ]) assert.equal(navigate(w, request), undefined);
+});
+
+test("activation requires an explicit message from a controlled-scope page", async () => {
+ const w = worker();
+ assert.equal(w.listeners.install, undefined);
+ assert.equal(w.listeners.activate, undefined);
+ const send = async (url, type = "rastrillo:pwa:activate") => {
+ let pending;
+ w.listeners.message({ data: { type }, source: { url }, waitUntil: (p) => { pending = p; } });
+ await pending;
+ };
+ await send("https://elsewhere.example/app/");
+ await send("https://app.example/other/");
+ await send("https://app.example/app/", "unrelated");
+ assert.equal(w.activations(), 0);
+ await send("https://app.example/app/");
+ assert.equal(w.activations(), 1);
+ assert.throws(() => w.self.RastrilloPWA.install(), /once/);
+});