@alxia/react-router 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +301 -13
- package/dist/chunks/index-59vhfj6n.js +10 -0
- package/dist/chunks/index-59vhfj6n.js.map +10 -0
- package/dist/chunks/index-5gqcpyyg.js +34 -0
- package/dist/chunks/index-5gqcpyyg.js.map +10 -0
- package/dist/chunks/{index-zqa30gr7.js → index-9xv2a8et.js} +7 -38
- package/dist/chunks/index-9xv2a8et.js.map +11 -0
- package/dist/chunks/index-mppz0806.js +119 -0
- package/dist/chunks/index-mppz0806.js.map +11 -0
- package/dist/chunks/index-rfr02vg4.js +40 -0
- package/dist/chunks/index-rfr02vg4.js.map +10 -0
- package/dist/chunks/index-zdbqzcjp.js +97 -0
- package/dist/chunks/index-zdbqzcjp.js.map +12 -0
- package/dist/errors/api-error.d.ts +86 -0
- package/dist/errors/api-error.d.ts.map +1 -0
- package/dist/errors/api-errors.d.ts +96 -0
- package/dist/errors/api-errors.d.ts.map +1 -0
- package/dist/errors/call-failure.d.ts +45 -0
- package/dist/errors/call-failure.d.ts.map +1 -0
- package/dist/errors/catalogue-check.d.ts +24 -0
- package/dist/errors/catalogue-check.d.ts.map +1 -0
- package/dist/errors/catalogue-read.d.ts +33 -0
- package/dist/errors/catalogue-read.d.ts.map +1 -0
- package/dist/errors/catalogue.d.ts +59 -0
- package/dist/errors/catalogue.d.ts.map +1 -0
- package/dist/errors/code-resolver.d.ts +44 -0
- package/dist/errors/code-resolver.d.ts.map +1 -0
- package/dist/errors/describe-refusal.d.ts +6 -0
- package/dist/errors/describe-refusal.d.ts.map +1 -0
- package/dist/errors/describe.d.ts +3 -0
- package/dist/errors/describe.d.ts.map +1 -0
- package/dist/errors/fetcher-result.d.ts +21 -0
- package/dist/errors/fetcher-result.d.ts.map +1 -0
- package/dist/errors/form-errors.d.ts +8 -0
- package/dist/errors/form-errors.d.ts.map +1 -0
- package/dist/errors/index.d.ts +15 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +602 -0
- package/dist/errors/index.js.map +29 -0
- package/dist/errors/json-actions.d.ts +38 -0
- package/dist/errors/json-actions.d.ts.map +1 -0
- package/dist/errors/log.d.ts +8 -0
- package/dist/errors/log.d.ts.map +1 -0
- package/dist/errors/message-values.d.ts +11 -0
- package/dist/errors/message-values.d.ts.map +1 -0
- package/dist/errors/messages.d.ts +63 -0
- package/dist/errors/messages.d.ts.map +1 -0
- package/dist/errors/page-path.d.ts +9 -0
- package/dist/errors/page-path.d.ts.map +1 -0
- package/dist/errors/provider.d.ts +20 -0
- package/dist/errors/provider.d.ts.map +1 -0
- package/dist/errors/request-id.d.ts +5 -0
- package/dist/errors/request-id.d.ts.map +1 -0
- package/dist/errors/route-error.d.ts +31 -0
- package/dist/errors/route-error.d.ts.map +1 -0
- package/dist/errors/standard-schema.d.ts +34 -0
- package/dist/errors/standard-schema.d.ts.map +1 -0
- package/dist/errors/submit-form.d.ts +16 -0
- package/dist/errors/submit-form.d.ts.map +1 -0
- package/dist/errors/use-json-fetcher.d.ts +18 -0
- package/dist/errors/use-json-fetcher.d.ts.map +1 -0
- package/dist/pwa/build/build-worker.d.ts +41 -0
- package/dist/pwa/build/build-worker.d.ts.map +1 -0
- package/dist/pwa/build/index.d.ts +9 -0
- package/dist/pwa/build/index.d.ts.map +1 -0
- package/dist/pwa/build/index.js +9 -0
- package/dist/pwa/build/index.js.map +9 -0
- package/dist/pwa/build/precache.d.ts +17 -0
- package/dist/pwa/build/precache.d.ts.map +1 -0
- package/dist/pwa/index.js +34 -134
- package/dist/pwa/index.js.map +4 -8
- package/dist/pwa/react/index.js +1 -1
- package/dist/pwa/worker/index.d.ts +9 -0
- package/dist/pwa/worker/index.d.ts.map +1 -0
- package/dist/pwa/worker/index.js +144 -0
- package/dist/pwa/worker/index.js.map +12 -0
- package/dist/pwa/worker/injected.d.ts +15 -0
- package/dist/pwa/worker/injected.d.ts.map +1 -0
- package/dist/pwa/worker/plan.d.ts +65 -0
- package/dist/pwa/worker/plan.d.ts.map +1 -0
- package/dist/pwa/worker/scope.d.ts +18 -0
- package/dist/pwa/worker/scope.d.ts.map +1 -0
- package/dist/pwa/worker/start-worker.d.ts +17 -0
- package/dist/pwa/worker/start-worker.d.ts.map +1 -0
- package/dist/vite/index.d.ts +10 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +49 -3
- package/dist/vite/index.js.map +6 -5
- package/dist/vite/runtime.d.ts +1 -1
- package/dist/vite/runtime.d.ts.map +1 -1
- package/dist/vite/worker.d.ts +16 -0
- package/dist/vite/worker.d.ts.map +1 -0
- package/docs/README.md +3 -2
- package/docs/guide/errors.md +838 -0
- package/docs/guide/how-it-works.md +7 -1
- package/docs/guide/pwa.md +372 -211
- package/docs/roadmap.md +38 -5
- package/docs/troubleshooting.md +689 -3
- package/package.json +56 -4
- package/dist/chunks/index-zqa30gr7.js.map +0 -12
|
@@ -72,9 +72,15 @@ bun run build
|
|
|
72
72
|
PORT=8080 bun run start
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
After React Router's builder has written the client, the plugin builds the
|
|
76
|
+
service worker when the app has an `app/service-worker.ts` (`worker: false`
|
|
77
|
+
turns it off): `build/client/service-worker.js`, with the precache list read
|
|
78
|
+
from the client build ([the PWA guide](pwa.md#zero-config-alxia-builds-it)).
|
|
79
|
+
|
|
75
80
|
React Router's `serverBundles` split the server build in several, and
|
|
76
81
|
alxia serves one: the plugin refuses them. With `ssr: false`, a
|
|
77
|
-
single-page app, the plugin does nothing
|
|
82
|
+
single-page app, the plugin does nothing but build the service worker (when
|
|
83
|
+
`app/service-worker.ts` exists).
|
|
78
84
|
|
|
79
85
|
## Under `vite preview`
|
|
80
86
|
|
package/docs/guide/pwa.md
CHANGED
|
@@ -1,50 +1,41 @@
|
|
|
1
1
|
# Installable and offline
|
|
2
2
|
|
|
3
3
|
This page covers what an installable React Router app shares, that has no
|
|
4
|
-
UI: the service worker's contract,
|
|
5
|
-
|
|
6
|
-
worker and offering an update,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
prefix, the paths never cached) is an option, so two apps never share
|
|
11
|
-
default.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
the app's for now: the [roadmap](../roadmap.md#next) lists them.
|
|
15
|
-
|
|
16
|
-
- [What the subpath holds](#what-the-subpath-holds)
|
|
4
|
+
UI: the service worker's contract, the worker itself and the build that
|
|
5
|
+
writes it, which requests the worker caches, which responses it may store,
|
|
6
|
+
the offline page, registering the worker and offering an update, serving the
|
|
7
|
+
worker's script, the web app manifest, and the cleaning at sign-out. It is
|
|
8
|
+
read in order: the contract, `app/service-worker.ts`, `public/offline.html`,
|
|
9
|
+
`alxia()`, `useServiceWorker`, then `noCache`. Every app-specific name (the
|
|
10
|
+
cache prefix, the paths never cached) is an option, so two apps never share
|
|
11
|
+
a default.
|
|
12
|
+
|
|
13
|
+
- [What the subpaths hold](#what-the-subpaths-hold)
|
|
17
14
|
- [The worker's contract](#the-workers-contract)
|
|
15
|
+
- [The worker](#the-worker)
|
|
18
16
|
- [What the worker caches](#what-the-worker-caches)
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
17
|
+
- [A worker of your own](#a-worker-of-your-own)
|
|
18
|
+
- [The offline page](#the-offline-page)
|
|
19
|
+
- [Building the worker](#building-the-worker)
|
|
22
20
|
- [In React](#in-react)
|
|
23
|
-
- [
|
|
21
|
+
- [Registering without React](#registering-without-react)
|
|
24
22
|
- [Serving the worker's script](#serving-the-workers-script)
|
|
23
|
+
- [The web app manifest (and why the language travels in `?lang=`)](#the-web-app-manifest-and-why-the-language-travels-in-lang)
|
|
24
|
+
- [Clearing at sign-out](#clearing-at-sign-out)
|
|
25
25
|
|
|
26
|
-
## What the
|
|
27
|
-
|
|
28
|
-
`@alxia/react-router/pwa` imports nothing (`boundary.spec.ts` checks the built
|
|
29
|
-
file), and its code reads `caches` and other globals only inside its functions,
|
|
30
|
-
never at load. So each runtime can import it:
|
|
31
|
-
|
|
32
|
-
| runtime | imports it for |
|
|
33
|
-
| --- | --- |
|
|
34
|
-
| the service worker | the contract, `cachingFor`, `isStorable` |
|
|
35
|
-
| the page | the contract (the same names), `registerServiceWorker`, `createUpdateNotice`, `manifestHref`, `clearAppCaches` |
|
|
36
|
-
| a build script | `parseBrowserManifest`, `precacheList`, `assetPrecache` |
|
|
37
|
-
| the server | `SERVICE_WORKER_URL`, to serve the worker under its path; `MANIFEST_PATH`, `MANIFEST_HEADERS`, `manifestLanguage` and `webAppManifest`, to serve the manifest |
|
|
26
|
+
## What the subpaths hold
|
|
38
27
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
core for it (`boundary.spec.ts` checks both). Keep it out of the worker and
|
|
43
|
-
the build script, which would bundle React.
|
|
28
|
+
Each subpath is for one runtime, and the boundary between them is checked
|
|
29
|
+
(`boundary.spec.ts` reads the built files). The code reads `caches` and other
|
|
30
|
+
globals only inside its functions, never at load.
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
| subpath | for | needs |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `/pwa` | every runtime: the contract, `cachingFor`, `isStorable`, `registerServiceWorker`, `createUpdateNotice`, `manifestHref`, `webAppManifest`, `clearAppCaches`, the precache parts and `SERVICE_WORKER_URL` | nothing: it imports nothing |
|
|
35
|
+
| `/pwa/react` | the page's hooks: `useServiceWorker`, `useUpdateNotice`, `useOnline` | `react` (`^19`), an optional peer. Keep it out of the worker, which would bundle React |
|
|
36
|
+
| `/pwa/server` | the server file alone: `noCache` and `revalidated` | `@alxia/core`, a peer the package already has |
|
|
37
|
+
| `/pwa/worker` | the service worker bundle alone: `startWorker` | the six `workbox-*` packages (`core`, `expiration`, `navigation-preload`, `precaching`, `routing`, `strategies`), optional peers (`^7`) |
|
|
38
|
+
| `/pwa/build` | a build pipeline of your own: `buildWorker` | Bun; Node's built-ins are its only imports, and it reads `Bun` only inside the function. The Workbox peers must be installed to bundle the worker |
|
|
48
39
|
|
|
49
40
|
The main entry, `@alxia/react-router`, is not for those: it imports
|
|
50
41
|
`@alxia/core`, which imports `node:*`, and a client bundle should not carry it.
|
|
@@ -79,22 +70,65 @@ The contract gives three cache names, `cacheNames.precache`
|
|
|
79
70
|
(`my-app-precache-v1`, the name Workbox builds from
|
|
80
71
|
`setCacheNameDetails({ prefix, precache: 'precache', suffix: version })`),
|
|
81
72
|
`cacheNames.assets` and `cacheNames.static`, and two filters over
|
|
82
|
-
`caches.keys()
|
|
73
|
+
`caches.keys()`.
|
|
74
|
+
|
|
75
|
+
`obsoleteCaches(names)` returns the prefix's caches that are not current;
|
|
76
|
+
`clearableCaches(names)` returns all of the prefix's but the current
|
|
77
|
+
precache. `startWorker` calls the first when it activates, so the app deletes
|
|
78
|
+
no cache itself. The module also exports `SERVICE_WORKER_URL`, `OFFLINE_URL` and
|
|
79
|
+
`SKIP_WAITING`, the message a page posts to a waiting worker.
|
|
80
|
+
|
|
81
|
+
## The worker
|
|
82
|
+
|
|
83
|
+
`startWorker` turns those answers into Workbox routes. The app's worker is
|
|
84
|
+
one file, bundled by the app's build (`app/service-worker.ts` by default):
|
|
83
85
|
|
|
84
86
|
```ts
|
|
85
|
-
// file:
|
|
86
|
-
import {
|
|
87
|
+
// file: app/service-worker.ts
|
|
88
|
+
import { startWorker } from '@alxia/react-router/pwa/worker';
|
|
89
|
+
import { workerContract } from './pwa-contract';
|
|
87
90
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
startWorker({
|
|
92
|
+
contract: workerContract,
|
|
93
|
+
caching: { never: (path) => path.startsWith('/api/') },
|
|
94
|
+
// The build writes the precache list in (see "Building the worker").
|
|
95
|
+
});
|
|
92
96
|
```
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
It uses no worker global, so it compiles under the app's DOM `tsconfig`. Add
|
|
99
|
+
the six `workbox-*` packages it bundles as development dependencies
|
|
100
|
+
([the install command](../../README.md#installable-and-offline-pwa)).
|
|
101
|
+
|
|
102
|
+
| option | default | |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `contract` | required | the app's `WorkerContract`: cache names, offline page, the worker's URL |
|
|
105
|
+
| `caching` | none | `never`, `immutable`, `manifestPath` and `serviceWorkerUrl`, as `cachingFor` takes them |
|
|
106
|
+
| `assets` | `{ maxEntries: 200 }` | how many hashed bundles the cache keeps (`maxEntries`, `maxAgeSeconds`) |
|
|
107
|
+
| `static` | `{ maxEntries: 100, maxAgeSeconds: 2592000 }` | the same for the other static files (30 days) |
|
|
108
|
+
| `navigationPreload` | `true` | the browser starts a page load's request alongside the worker |
|
|
109
|
+
| `precache` | the list the build wrote in | `[{ url, revision }]`; a worker with neither throws at once |
|
|
110
|
+
|
|
111
|
+
What each answer of `cachingFor` becomes:
|
|
112
|
+
|
|
113
|
+
| answer | Workbox | stores |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `navigation` | `NetworkOnly`; the precached offline page answers when the network fails (not for an HTTP error), if the contract's `offlineUrl` is in the precache list | nothing |
|
|
116
|
+
| `immutable` | `CacheFirst` on `cacheNames.assets`, with `ExpirationPlugin` | what `isStorable` accepts |
|
|
117
|
+
| `static` | `StaleWhileRevalidate` on `cacheNames.static`, with `ExpirationPlugin` | what `isStorable` accepts |
|
|
118
|
+
| `none` | no route: the browser | nothing |
|
|
119
|
+
|
|
120
|
+
Around them: the precache route is registered first, with exact URLs only
|
|
121
|
+
(`cleanURLs: false`, no directory index), so a page load still goes to the
|
|
122
|
+
network; `clientsClaim()` makes a first install control the open pages at
|
|
123
|
+
once; an update waits until the page posts `SKIP_WAITING`; and on `activate`
|
|
124
|
+
the caches the contract no longer owns (`obsoleteCaches`) are deleted.
|
|
125
|
+
|
|
126
|
+
The precache list is the `precache` option, or else
|
|
127
|
+
`globalThis.__ALXIA_PRECACHE__`, which the build writes into the bundle (a
|
|
128
|
+
worker bundled some other way passes `precache`, or defines that global in its
|
|
129
|
+
own bundle). Read without the build it is `undefined`, so the
|
|
130
|
+
worker's error is [`startWorker(): no precache list`](../troubleshooting.md#startworker-no-precache-list-)
|
|
131
|
+
instead of a `ReferenceError`.
|
|
98
132
|
|
|
99
133
|
## What the worker caches
|
|
100
134
|
|
|
@@ -113,47 +147,6 @@ the worker's own path, the manifest's, a document or iframe fetch, and any
|
|
|
113
147
|
path with no static extension. The options add to that, or move the line
|
|
114
148
|
between `immutable` and `static`:
|
|
115
149
|
|
|
116
|
-
```ts
|
|
117
|
-
// file: sw/strategy.ts
|
|
118
|
-
import { cachingFor, isStorable } from '@alxia/react-router/pwa';
|
|
119
|
-
|
|
120
|
-
const options = {
|
|
121
|
-
never: (path: string) => path.startsWith('/api/'),
|
|
122
|
-
immutable: (path: string) =>
|
|
123
|
-
path.startsWith('/assets/') && !path.startsWith('/assets/icons/'),
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export function strategyOf(request: Request) {
|
|
127
|
-
return cachingFor(request, self.location.origin, options);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function mayStore(response: Response) {
|
|
131
|
-
return isStorable(response);
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
The worker's fetch handler asks both. A cache-first answer for the
|
|
136
|
-
`immutable` words reads the contract's assets cache and falls back to the
|
|
137
|
-
network:
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
// file: sw/answer.ts
|
|
141
|
-
import { workerContract } from '../app/pwa-contract';
|
|
142
|
-
import { mayStore, strategyOf } from './strategy';
|
|
143
|
-
|
|
144
|
-
export async function answer(request: Request): Promise<Response> {
|
|
145
|
-
if (strategyOf(request) === 'immutable') {
|
|
146
|
-
const cache = await caches.open(workerContract.cacheNames.assets);
|
|
147
|
-
const cached = await cache.match(request);
|
|
148
|
-
if (cached) return cached;
|
|
149
|
-
const response = await fetch(request);
|
|
150
|
-
if (mayStore(response)) await cache.put(request, response.clone());
|
|
151
|
-
return response;
|
|
152
|
-
}
|
|
153
|
-
return fetch(request);
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
150
|
| option | default | |
|
|
158
151
|
| --- | --- | --- |
|
|
159
152
|
| `never(path)` | none | paths of the app always answered fresh |
|
|
@@ -173,11 +166,173 @@ same-origin 200 of a script, style, font or image, that the server marked
|
|
|
173
166
|
image, such as a user's photo, does not), neither `private` nor `no-store`,
|
|
174
167
|
and that does not vary on `Cookie`, `Authorization` or `*`.
|
|
175
168
|
|
|
176
|
-
##
|
|
169
|
+
## A worker of your own
|
|
170
|
+
|
|
171
|
+
When Workbox does not fit, `cachingFor` and `isStorable` need none: the
|
|
172
|
+
worker asks the first of the request and the second of the answer.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// file: app/pwa/answer.ts
|
|
176
|
+
import { cachingFor, isStorable } from '@alxia/react-router/pwa';
|
|
177
|
+
import { workerContract } from '../pwa-contract';
|
|
178
|
+
|
|
179
|
+
export async function answer(request: Request): Promise<Response> {
|
|
180
|
+
if (cachingFor(request, self.location.origin, { contract: workerContract }) !== 'immutable') {
|
|
181
|
+
return fetch(request);
|
|
182
|
+
}
|
|
183
|
+
const cache = await caches.open(workerContract.cacheNames.assets);
|
|
184
|
+
const cached = await cache.match(request);
|
|
185
|
+
if (cached) return cached;
|
|
186
|
+
const response = await fetch(request);
|
|
187
|
+
if (isStorable(response)) await cache.put(request, response.clone());
|
|
188
|
+
return response;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## The offline page
|
|
193
|
+
|
|
194
|
+
The worker precaches `public/offline.html` (the contract's `offlineUrl`) and
|
|
195
|
+
answers a page load with it when the network fails. It is the one document in
|
|
196
|
+
`build/client` that no route renders, so `noCache` does not touch it and it
|
|
197
|
+
carries its policy in its own `<meta>`, which loads nothing:
|
|
198
|
+
|
|
199
|
+
```html
|
|
200
|
+
<!-- file: public/offline.html -->
|
|
201
|
+
<!doctype html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="utf-8" />
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
206
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'" />
|
|
207
|
+
<title>Offline</title>
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
<h1>You are offline</h1>
|
|
211
|
+
<p>Check your connection and try again.</p>
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
An inline `<style>` or `<script>` is refused by that policy unless its
|
|
217
|
+
`sha256-` hash is added to `style-src` or `script-src`. The build revisions
|
|
218
|
+
the file by its content: a changed page installs a new worker. A worker with
|
|
219
|
+
no offline page passes `offline: false` to the build.
|
|
220
|
+
|
|
221
|
+
## Building the worker
|
|
222
|
+
|
|
223
|
+
### Zero config: `alxia()` builds it
|
|
224
|
+
|
|
225
|
+
With `app/service-worker.ts` in the app, `react-router build` writes
|
|
226
|
+
`build/client/service-worker.js`: `alxia()` bundles the file after React Router's
|
|
227
|
+
client build, with the precache list written in, and logs how many files it
|
|
228
|
+
precaches. The file is the opt-in, as `app/server.ts` is, so an app without one
|
|
229
|
+
gets nothing. It needs Bun: run `bun --bun react-router build`, or put `[run]`
|
|
230
|
+
and `bun = true` in a `bunfig.toml`. A single-page app (`ssr: false`) gets its
|
|
231
|
+
worker too.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// file: vite.config.ts
|
|
235
|
+
import { alxia } from '@alxia/react-router/vite';
|
|
236
|
+
import { reactRouter } from '@react-router/dev/vite';
|
|
237
|
+
import { defineConfig } from 'vite';
|
|
238
|
+
|
|
239
|
+
export default defineConfig({
|
|
240
|
+
plugins: [
|
|
241
|
+
reactRouter(),
|
|
242
|
+
alxia({ worker: { precacheAssets: /^body-latin-[\w-]{8}\.woff2$/ } }),
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
| `worker` | |
|
|
248
|
+
| --- | --- |
|
|
249
|
+
| left out | `app/service-worker.ts`, when it exists |
|
|
250
|
+
| a string | another file, relative to Vite's root; the build fails when it is not there |
|
|
251
|
+
| `false` | no worker is built |
|
|
252
|
+
| an object | `file` (as the string), `contract`, `offline`, `precacheAssets` and `minify`, as in `buildWorker`'s table below |
|
|
253
|
+
|
|
254
|
+
The object form takes the options of [`buildWorker`](#with-buildworker), but
|
|
255
|
+
`root` and `client`, which the plugin knows. Its `contract` must be the same
|
|
256
|
+
object `startWorker` gets: the build reads the worker's URL and the offline
|
|
257
|
+
page from it, and the worker reads them from its own.
|
|
258
|
+
|
|
259
|
+
### Moving from a build script of your own
|
|
260
|
+
|
|
261
|
+
An app that built its worker with a script of its own (a `sw/` folder with its
|
|
262
|
+
own `tsconfig.json`, a `build:sw` script) moves to `alxia()` in a few steps:
|
|
263
|
+
|
|
264
|
+
1. Drop `&& bun run build:sw` from the `build` script and delete the `build:sw`
|
|
265
|
+
script. Otherwise the old script overwrites `build/client/service-worker.js`
|
|
266
|
+
after `alxia()` has written it.
|
|
267
|
+
2. Delete `sw/` (`service-worker.ts` and `tsconfig.json`) and
|
|
268
|
+
`scripts/build-sw.ts`. The worker is `app/service-worker.ts`, or the file
|
|
269
|
+
`alxia({ worker: 'path' })` names.
|
|
270
|
+
3. Drop `&& tsc -p sw` from the `typecheck` script: `app/service-worker.ts`
|
|
271
|
+
compiles under the app's own `tsconfig`.
|
|
272
|
+
4. Keep the six `workbox-*` development dependencies: the build bundles them.
|
|
273
|
+
5. Keep `routeDiscovery: { mode: 'initial' }` in `react-router.config.ts`
|
|
274
|
+
([why](../troubleshooting.md#offline-a-client-navigation-to-a-route-never-visited-shows-the-offline-page)).
|
|
275
|
+
6. Move the regex of the fonts the old script precached into
|
|
276
|
+
`alxia({ worker: { precacheAssets } })`.
|
|
277
|
+
7. If the contract changes `offlineUrl` or `serviceWorkerUrl`, pass it to the
|
|
278
|
+
build: `alxia({ worker: { contract: workerContract } })`.
|
|
279
|
+
|
|
280
|
+
### With `buildWorker`
|
|
281
|
+
|
|
282
|
+
`buildWorker` is for a pipeline where `alxia()` does not build the worker. It
|
|
283
|
+
bundles the worker (Workbox included) into the client build, after
|
|
284
|
+
`react-router build`, with the precache list written in. It runs on Bun and
|
|
285
|
+
writes `build/client/service-worker.js` (the contract's `serviceWorkerUrl`):
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
// file: scripts/build-worker.ts
|
|
289
|
+
import { buildWorker } from '@alxia/react-router/pwa/build';
|
|
290
|
+
|
|
291
|
+
const { precache, precacheBytes, bytes } = await buildWorker({
|
|
292
|
+
file: 'app/service-worker.ts',
|
|
293
|
+
precacheAssets: /^body-latin-[\w-]{8}\.woff2$/,
|
|
294
|
+
});
|
|
295
|
+
console.log(
|
|
296
|
+
`${precache.length} files precached (${precacheBytes} bytes), worker ${bytes} bytes`,
|
|
297
|
+
);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Run it as `bun scripts/build-worker.ts` after `react-router build`. The list is
|
|
301
|
+
the offline page, revisioned by its content (a changed page installs a new
|
|
302
|
+
worker), then every route's code, then the `precacheAssets` files. A new build
|
|
303
|
+
changes the list, so the worker's bytes, so the browser installs it.
|
|
304
|
+
|
|
305
|
+
| option | default | |
|
|
306
|
+
| --- | --- | --- |
|
|
307
|
+
| `file` | required | the worker's source, relative to `root` (named `file`, not `entry`, which `reveal` reads from `alxia({ entry })`) |
|
|
308
|
+
| `root` | `process.cwd()` | |
|
|
309
|
+
| `client` | `build/client` | the client build, relative to `root` |
|
|
310
|
+
| `contract` | `SERVICE_WORKER_URL`, `OFFLINE_URL` | `{ serviceWorkerUrl, offlineUrl }`: where the worker is written and which offline page is precached. Pass the same object `startWorker` gets (the app's contract), or the build and the worker can disagree |
|
|
311
|
+
| `offline` | `true` | precache the offline page, which must exist in the client build ([`public/offline.html`](#the-offline-page)) |
|
|
312
|
+
| `precacheAssets` | none | a regex over the names in `build/client/assets`, such as fonts |
|
|
313
|
+
| `minify` | `true` | |
|
|
314
|
+
|
|
315
|
+
The result is `{ file, precache, precacheBytes, bytes }`: the absolute path,
|
|
316
|
+
the list, the precached files' size before compression and the worker's size.
|
|
317
|
+
Its errors are in [the troubleshooting page](../troubleshooting.md#buildworker-expected-one-react-router-browser-manifest).
|
|
318
|
+
|
|
319
|
+
### The parts
|
|
177
320
|
|
|
178
321
|
A worker precaches the code of every route, so a client navigation offline to
|
|
179
|
-
a route never visited still finds its modules.
|
|
180
|
-
|
|
322
|
+
a route never visited still finds its modules. Set
|
|
323
|
+
`routeDiscovery: { mode: 'initial' }` in `react-router.config.ts` as well,
|
|
324
|
+
or the navigation asks `/__manifest` for the route and shows the offline page
|
|
325
|
+
([more](../troubleshooting.md#offline-a-client-navigation-to-a-route-never-visited-shows-the-offline-page)):
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import type { Config } from '@react-router/dev/config';
|
|
329
|
+
|
|
330
|
+
export default { ssr: true, routeDiscovery: { mode: 'initial' } } satisfies Config;
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`react-router build` writes the
|
|
334
|
+
list in `build/client/assets/manifest-*.js`. `buildWorker` does this itself; the parts
|
|
335
|
+
are for a pipeline of your own:
|
|
181
336
|
|
|
182
337
|
```ts
|
|
183
338
|
// file: scripts/precache.ts
|
|
@@ -212,8 +367,89 @@ the files a regex of yours matches, such as fonts, sorted, under `/assets/`
|
|
|
212
367
|
or `base`. Chunks only a component loads on demand stay out: the cache-first
|
|
213
368
|
route stores them the first time they load.
|
|
214
369
|
|
|
215
|
-
##
|
|
370
|
+
## In React
|
|
371
|
+
|
|
372
|
+
`@alxia/react-router/pwa/react` wraps the page's side in three hooks and the
|
|
373
|
+
page's one notice. It needs `react` (`bun add react`, which a React Router app
|
|
374
|
+
has already).
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
// file: app/root.tsx
|
|
378
|
+
import { manifestHref } from '@alxia/react-router/pwa';
|
|
379
|
+
import { useServiceWorker } from '@alxia/react-router/pwa/react';
|
|
380
|
+
import { Outlet } from 'react-router';
|
|
381
|
+
import { UpdateBanner } from './update-banner';
|
|
382
|
+
|
|
383
|
+
// The manifest, below, makes the app installable.
|
|
384
|
+
export function links() {
|
|
385
|
+
return [{ rel: 'manifest', href: manifestHref('fr') }];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export default function App() {
|
|
389
|
+
useServiceWorker({
|
|
390
|
+
enabled: import.meta.env.PROD,
|
|
391
|
+
onError: (error) => console.warn('No service worker', error),
|
|
392
|
+
});
|
|
393
|
+
return (
|
|
394
|
+
<>
|
|
395
|
+
<UpdateBanner />
|
|
396
|
+
<Outlet />
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// file: app/update-banner.tsx
|
|
404
|
+
import { useOnline, useUpdateNotice } from '@alxia/react-router/pwa/react';
|
|
405
|
+
|
|
406
|
+
export function UpdateBanner() {
|
|
407
|
+
const notice = useUpdateNotice();
|
|
408
|
+
const online = useOnline();
|
|
409
|
+
if (!online) return <p role="status">You are offline.</p>;
|
|
410
|
+
if (!notice) return null;
|
|
411
|
+
return (
|
|
412
|
+
<p role="status">
|
|
413
|
+
A new version is ready.
|
|
414
|
+
<button type="button" onClick={notice.reload}>Reload</button>
|
|
415
|
+
<button type="button" onClick={notice.later}>Later</button>
|
|
416
|
+
</p>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
- **`useServiceWorker({ enabled })`** registers the worker after the page has
|
|
422
|
+
loaded, when `enabled` and the browser has `navigator.serviceWorker`. The
|
|
423
|
+
app passes `enabled`, usually `import.meta.env.PROD`: this package never
|
|
424
|
+
reads `import.meta.env`, since a bundler replaces it in a dependency only
|
|
425
|
+
by chance of its pre-bundling, and Bun and tests never do. `url` and `scope`
|
|
426
|
+
are `registerServiceWorker`'s. `onError` is called when registering fails
|
|
427
|
+
(private mode, a blocked script); the app works without a worker, so by
|
|
428
|
+
default the error is dropped. The effect runs again only when `enabled`,
|
|
429
|
+
`url`, `scope` or `notice` change, never for a new inline `onError` (the
|
|
430
|
+
latest one is always called). The options are read when the effect runs,
|
|
431
|
+
and the listeners it adds (the update check when the tab shows, the reload
|
|
432
|
+
on a controller change) stay for the page's life: there is no cleanup.
|
|
433
|
+
- **`useUpdateNotice(notice?)`** is `useSyncExternalStore` over the store: the
|
|
434
|
+
`{ reload, later }` while a version waits, else `undefined`, and always
|
|
435
|
+
`undefined` on the server and while hydrating.
|
|
436
|
+
- **`useOnline()`** is `navigator.onLine`, `true` on the server and while
|
|
437
|
+
hydrating, so the first client render matches the server's.
|
|
438
|
+
- **`updateNotice`** is the page's one store. It is made in this subpath, not in
|
|
439
|
+
`/pwa`, so the model a worker bundle imports does nothing at load. Both
|
|
440
|
+
hooks use it unless given another `notice`; pass the same store to both
|
|
441
|
+
when you make your own with `createUpdateNotice(storage, key)`.
|
|
216
442
|
|
|
443
|
+
A banner from a component library takes the notice as a prop, so it depends
|
|
444
|
+
on neither this package nor React Router: `<UpdateBanner notice={useUpdateNotice()} />`.
|
|
445
|
+
A spec or a story passes a notice of its own, made with `createUpdateNotice`
|
|
446
|
+
and `offer(() => {})`, and the quality checks that show the banner in
|
|
447
|
+
development call `updateNotice.offer(() => location.reload())` from an effect
|
|
448
|
+
of the app, which this package leaves out.
|
|
449
|
+
|
|
450
|
+
## Registering without React
|
|
451
|
+
|
|
452
|
+
The hooks of [In React](#in-react) wrap this. For another UI,
|
|
217
453
|
`registerServiceWorker(navigator.serviceWorker, onWaiting, options?)`
|
|
218
454
|
registers the worker (`options.url`, `/service-worker.js` by default, and
|
|
219
455
|
`options.scope`, `'/'`) and calls `onWaiting(worker, apply)` for each new
|
|
@@ -235,7 +471,7 @@ import {
|
|
|
235
471
|
|
|
236
472
|
export const notice = createUpdateNotice(sessionStore);
|
|
237
473
|
|
|
238
|
-
export function
|
|
474
|
+
export function registerWorker() {
|
|
239
475
|
if (!('serviceWorker' in navigator)) return;
|
|
240
476
|
afterLoad(() => {
|
|
241
477
|
registerServiceWorker(navigator.serviceWorker, (worker, apply) =>
|
|
@@ -270,13 +506,53 @@ that page. `storage()` may return any object with `getItem(key)` and
|
|
|
270
506
|
(`NoticeStorage`). The notice and the registration touch no global at load, and
|
|
271
507
|
neither imports React.
|
|
272
508
|
|
|
509
|
+
## Serving the worker's script
|
|
510
|
+
|
|
511
|
+
Two things are true of the worker's script and of no other static file. It
|
|
512
|
+
must be revalidated on every update check: the client's top-level files are
|
|
513
|
+
served for an hour, and an update would wait that long. And it takes the
|
|
514
|
+
policy its own response carries, not the pages': it runs no other script and
|
|
515
|
+
fetches this site only. `noCache` sets `Cache-Control: no-cache` and
|
|
516
|
+
`WORKER_POLICY` (`default-src 'none'; connect-src 'self'`) on the response of
|
|
517
|
+
the path it is declared for.
|
|
518
|
+
|
|
519
|
+
Declare it in `beforeAll`, which runs before the client's files. A middleware
|
|
520
|
+
declared in `configure` comes after them, and the file answers before it
|
|
521
|
+
runs. In `app/server.ts` (the manifest's route, below, goes in the same `createServer` call):
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
import { createServer } from '@alxia/react-router';
|
|
525
|
+
import { SERVICE_WORKER_URL } from '@alxia/react-router/pwa';
|
|
526
|
+
import { noCache } from '@alxia/react-router/pwa/server';
|
|
527
|
+
|
|
528
|
+
export default createServer({
|
|
529
|
+
beforeAll: (app) => app.use(SERVICE_WORKER_URL, noCache),
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
A worker that fetches an API of another origin needs a wider policy. Write
|
|
534
|
+
the middleware with `revalidated(response, policy)`:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import { defineMiddleware } from '@alxia/core';
|
|
538
|
+
import { revalidated } from '@alxia/react-router/pwa/server';
|
|
539
|
+
|
|
540
|
+
const POLICY = "default-src 'none'; connect-src 'self' https://api.example.com";
|
|
541
|
+
|
|
542
|
+
export const workerScript = defineMiddleware(async (_ctx, next) =>
|
|
543
|
+
revalidated(await next(), POLICY),
|
|
544
|
+
);
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The offline page is the one other document in `build/client`: its policy is
|
|
548
|
+
[its own `<meta>`](#the-offline-page), and `noCache` does not touch it.
|
|
549
|
+
|
|
273
550
|
## The web app manifest (and why the language travels in `?lang=`)
|
|
274
551
|
|
|
275
552
|
A browser fetches the manifest without cookies, so a `language` cookie never
|
|
276
553
|
reaches it. The page names its language in the query, and the server reads it:
|
|
277
554
|
|
|
278
|
-
```tsx
|
|
279
|
-
// file: app/root.tsx
|
|
555
|
+
```tsx excerpt
|
|
280
556
|
import { manifestHref } from '@alxia/react-router/pwa';
|
|
281
557
|
|
|
282
558
|
export function links() {
|
|
@@ -340,80 +616,6 @@ Serve the manifest at `MANIFEST_PATH`, or at a path of your own that you give
|
|
|
340
616
|
both `manifestHref(language, path)` and `cachingFor`'s `manifestPath`. The
|
|
341
617
|
icons' files are yours: test that each `src` exists in `public/`.
|
|
342
618
|
|
|
343
|
-
## In React
|
|
344
|
-
|
|
345
|
-
`@alxia/react-router/pwa/react` wraps the page's side in three hooks and the
|
|
346
|
-
page's one notice. It needs `react` (`bun add react`, which a React Router app
|
|
347
|
-
has already).
|
|
348
|
-
|
|
349
|
-
```tsx
|
|
350
|
-
// file: app/app.tsx
|
|
351
|
-
import { useServiceWorker } from '@alxia/react-router/pwa/react';
|
|
352
|
-
import { Outlet } from 'react-router';
|
|
353
|
-
import { UpdateBanner } from './update-banner';
|
|
354
|
-
|
|
355
|
-
export default function App() {
|
|
356
|
-
useServiceWorker({
|
|
357
|
-
enabled: import.meta.env.PROD,
|
|
358
|
-
onError: (error) => console.warn('No service worker', error),
|
|
359
|
-
});
|
|
360
|
-
return (
|
|
361
|
-
<>
|
|
362
|
-
<UpdateBanner />
|
|
363
|
-
<Outlet />
|
|
364
|
-
</>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
```tsx
|
|
370
|
-
// file: app/update-banner.tsx
|
|
371
|
-
import { useOnline, useUpdateNotice } from '@alxia/react-router/pwa/react';
|
|
372
|
-
|
|
373
|
-
export function UpdateBanner() {
|
|
374
|
-
const notice = useUpdateNotice();
|
|
375
|
-
const online = useOnline();
|
|
376
|
-
if (!online) return <p role="status">You are offline.</p>;
|
|
377
|
-
if (!notice) return null;
|
|
378
|
-
return (
|
|
379
|
-
<p role="status">
|
|
380
|
-
A new version is ready.
|
|
381
|
-
<button type="button" onClick={notice.reload}>Reload</button>
|
|
382
|
-
<button type="button" onClick={notice.later}>Later</button>
|
|
383
|
-
</p>
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
- **`useServiceWorker({ enabled })`** registers the worker after the page has
|
|
389
|
-
loaded, when `enabled` and the browser has `navigator.serviceWorker`. The
|
|
390
|
-
app passes `enabled`, usually `import.meta.env.PROD`: this package never
|
|
391
|
-
reads `import.meta.env`, since a bundler replaces it in a dependency only
|
|
392
|
-
by chance of its pre-bundling, and Bun and tests never do. `url` and `scope`
|
|
393
|
-
are `registerServiceWorker`'s. `onError` is called when registering fails
|
|
394
|
-
(private mode, a blocked script); the app works without a worker, so by
|
|
395
|
-
default the error is dropped. The effect runs again only when `enabled`,
|
|
396
|
-
`url`, `scope` or `notice` change, never for a new inline `onError` (the
|
|
397
|
-
latest one is always called). The options are read when the effect runs,
|
|
398
|
-
and the listeners it adds (the update check when the tab shows, the reload
|
|
399
|
-
on a controller change) stay for the page's life: there is no cleanup.
|
|
400
|
-
- **`useUpdateNotice(notice?)`** is `useSyncExternalStore` over the store: the
|
|
401
|
-
`{ reload, later }` while a version waits, else `undefined`, and always
|
|
402
|
-
`undefined` on the server and while hydrating.
|
|
403
|
-
- **`useOnline()`** is `navigator.onLine`, `true` on the server and while
|
|
404
|
-
hydrating, so the first client render matches the server's.
|
|
405
|
-
- **`updateNotice`** is the page's one store. It is made in this subpath, not in
|
|
406
|
-
`/pwa`, so the model a worker bundle imports does nothing at load. Both
|
|
407
|
-
hooks use it unless given another `notice`; pass the same store to both
|
|
408
|
-
when you make your own with `createUpdateNotice(storage, key)`.
|
|
409
|
-
|
|
410
|
-
A banner from a component library takes the notice as a prop, so it depends
|
|
411
|
-
on neither this package nor React Router: `<UpdateBanner notice={useUpdateNotice()} />`.
|
|
412
|
-
A spec or a story passes a notice of its own, made with `createUpdateNotice`
|
|
413
|
-
and `offer(() => {})`, and the quality checks that show the banner in
|
|
414
|
-
development call `updateNotice.offer(() => location.reload())` from an effect
|
|
415
|
-
of the app, which this package leaves out.
|
|
416
|
-
|
|
417
619
|
## Clearing at sign-out
|
|
418
620
|
|
|
419
621
|
What the caches hold is static and public by design, and clearing them at
|
|
@@ -432,44 +634,3 @@ export async function signOut() {
|
|
|
432
634
|
await clearAppCaches(workerContract);
|
|
433
635
|
}
|
|
434
636
|
```
|
|
435
|
-
|
|
436
|
-
## Serving the worker's script
|
|
437
|
-
|
|
438
|
-
Two things are true of the worker's script and of no other static file. It
|
|
439
|
-
must be revalidated on every update check: the client's top-level files are
|
|
440
|
-
served for an hour, and an update would wait that long. And it takes the
|
|
441
|
-
policy its own response carries, not the pages': it runs no other script and
|
|
442
|
-
fetches this site only. `noCache` sets `Cache-Control: no-cache` and
|
|
443
|
-
`WORKER_POLICY` (`default-src 'none'; connect-src 'self'`) on the response of
|
|
444
|
-
the path it is declared for.
|
|
445
|
-
|
|
446
|
-
Declare it in `beforeAll`, which runs before the client's files. A middleware
|
|
447
|
-
declared in `configure` comes after them, and the file answers before it
|
|
448
|
-
runs. In `app/server.ts`, in the same `createServer` call as the manifest's route:
|
|
449
|
-
|
|
450
|
-
```ts
|
|
451
|
-
import { createServer } from '@alxia/react-router';
|
|
452
|
-
import { SERVICE_WORKER_URL } from '@alxia/react-router/pwa';
|
|
453
|
-
import { noCache } from '@alxia/react-router/pwa/server';
|
|
454
|
-
|
|
455
|
-
export default createServer({
|
|
456
|
-
beforeAll: (app) => app.use(SERVICE_WORKER_URL, noCache),
|
|
457
|
-
});
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
A worker that fetches an API of another origin needs a wider policy. Write
|
|
461
|
-
the middleware with `revalidated(response, policy)`:
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
import { defineMiddleware } from '@alxia/core';
|
|
465
|
-
import { revalidated } from '@alxia/react-router/pwa/server';
|
|
466
|
-
|
|
467
|
-
const POLICY = "default-src 'none'; connect-src 'self' https://api.example.com";
|
|
468
|
-
|
|
469
|
-
export const workerScript = defineMiddleware(async (_ctx, next) =>
|
|
470
|
-
revalidated(await next(), POLICY),
|
|
471
|
-
);
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
Declare the offline page's policy in its own `<meta>`: it is the one other
|
|
475
|
-
document in `build/client`, and `noCache` does not touch it.
|