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.
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 −0AGENTS.md+10 −0LICENSE+373 −0Makefile+17 −0README.md+118 −0SKILL.md+54 −0docs/offline.md+31 −0examples/basic/go.mod+32 −0examples/basic/go.sum+103 −0examples/basic/main.go+87 −0go.mod+3 −0js/client.mjs+32 −0js/worker.js+48 −0package-lock.json+60 −0package.json+6 −0pwa.go+126 −0pwa_test.go+73 −0test/browser.mjs+114 −0test/client.test.mjs+36 −0test/worker.test.mjs+71 −0
CI cancelled β superseded by a newer push — run details
Intent
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/); |
| +}); |