@alxia/react-router 0.8.1 → 0.10.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.
Files changed (94) hide show
  1. package/README.md +222 -10
  2. package/dist/chunks/index-59vhfj6n.js +10 -0
  3. package/dist/chunks/index-59vhfj6n.js.map +10 -0
  4. package/dist/chunks/index-5gqcpyyg.js +34 -0
  5. package/dist/chunks/index-5gqcpyyg.js.map +10 -0
  6. package/dist/chunks/index-9xv2a8et.js +108 -0
  7. package/dist/chunks/index-9xv2a8et.js.map +11 -0
  8. package/dist/chunks/index-mppz0806.js +119 -0
  9. package/dist/chunks/index-mppz0806.js.map +11 -0
  10. package/dist/chunks/index-rfr02vg4.js +40 -0
  11. package/dist/chunks/index-rfr02vg4.js.map +10 -0
  12. package/dist/chunks/index-zdbqzcjp.js +97 -0
  13. package/dist/chunks/index-zdbqzcjp.js.map +12 -0
  14. package/dist/pwa/build/build-worker.d.ts +41 -0
  15. package/dist/pwa/build/build-worker.d.ts.map +1 -0
  16. package/dist/pwa/build/index.d.ts +9 -0
  17. package/dist/pwa/build/index.d.ts.map +1 -0
  18. package/dist/pwa/build/index.js +9 -0
  19. package/dist/pwa/build/index.js.map +9 -0
  20. package/dist/pwa/build/precache.d.ts +17 -0
  21. package/dist/pwa/build/precache.d.ts.map +1 -0
  22. package/dist/pwa/index.d.ts +14 -0
  23. package/dist/pwa/index.d.ts.map +1 -0
  24. package/dist/pwa/index.js +65 -0
  25. package/dist/pwa/index.js.map +10 -0
  26. package/dist/pwa/model/caching.d.ts +46 -0
  27. package/dist/pwa/model/caching.d.ts.map +1 -0
  28. package/dist/pwa/model/clear-app-caches.d.ts +9 -0
  29. package/dist/pwa/model/clear-app-caches.d.ts.map +1 -0
  30. package/dist/pwa/model/manifest.d.ts +80 -0
  31. package/dist/pwa/model/manifest.d.ts.map +1 -0
  32. package/dist/pwa/model/precache-list.d.ts +40 -0
  33. package/dist/pwa/model/precache-list.d.ts.map +1 -0
  34. package/dist/pwa/model/registration.d.ts +39 -0
  35. package/dist/pwa/model/registration.d.ts.map +1 -0
  36. package/dist/pwa/model/start-service-worker.d.ts +24 -0
  37. package/dist/pwa/model/start-service-worker.d.ts.map +1 -0
  38. package/dist/pwa/model/storable.d.ts +8 -0
  39. package/dist/pwa/model/storable.d.ts.map +1 -0
  40. package/dist/pwa/model/update-notice.d.ts +38 -0
  41. package/dist/pwa/model/update-notice.d.ts.map +1 -0
  42. package/dist/pwa/model/worker-contract.d.ts +61 -0
  43. package/dist/pwa/model/worker-contract.d.ts.map +1 -0
  44. package/dist/pwa/react/index.d.ts +9 -0
  45. package/dist/pwa/react/index.d.ts.map +1 -0
  46. package/dist/pwa/react/index.js +75 -0
  47. package/dist/pwa/react/index.js.map +13 -0
  48. package/dist/pwa/react/use-online.d.ts +6 -0
  49. package/dist/pwa/react/use-online.d.ts.map +1 -0
  50. package/dist/pwa/react/use-service-worker.d.ts +33 -0
  51. package/dist/pwa/react/use-service-worker.d.ts.map +1 -0
  52. package/dist/pwa/react/use-update-notice.d.ts +14 -0
  53. package/dist/pwa/react/use-update-notice.d.ts.map +1 -0
  54. package/dist/pwa/server/index.d.ts +7 -0
  55. package/dist/pwa/server/index.d.ts.map +1 -0
  56. package/dist/pwa/server/index.js +18 -0
  57. package/dist/pwa/server/index.js.map +10 -0
  58. package/dist/pwa/server/no-cache.d.ts +22 -0
  59. package/dist/pwa/server/no-cache.d.ts.map +1 -0
  60. package/dist/pwa/worker/index.d.ts +9 -0
  61. package/dist/pwa/worker/index.d.ts.map +1 -0
  62. package/dist/pwa/worker/index.js +144 -0
  63. package/dist/pwa/worker/index.js.map +12 -0
  64. package/dist/pwa/worker/injected.d.ts +15 -0
  65. package/dist/pwa/worker/injected.d.ts.map +1 -0
  66. package/dist/pwa/worker/plan.d.ts +65 -0
  67. package/dist/pwa/worker/plan.d.ts.map +1 -0
  68. package/dist/pwa/worker/scope.d.ts +18 -0
  69. package/dist/pwa/worker/scope.d.ts.map +1 -0
  70. package/dist/pwa/worker/start-worker.d.ts +17 -0
  71. package/dist/pwa/worker/start-worker.d.ts.map +1 -0
  72. package/dist/vite/index.d.ts +10 -0
  73. package/dist/vite/index.d.ts.map +1 -1
  74. package/dist/vite/index.js +49 -3
  75. package/dist/vite/index.js.map +6 -5
  76. package/dist/vite/runtime.d.ts +1 -1
  77. package/dist/vite/runtime.d.ts.map +1 -1
  78. package/dist/vite/worker.d.ts +16 -0
  79. package/dist/vite/worker.d.ts.map +1 -0
  80. package/docs/README.md +12 -3
  81. package/docs/guide/beside-the-pages.md +178 -0
  82. package/docs/guide/csp-nonce.md +100 -0
  83. package/docs/guide/customising-the-server.md +292 -0
  84. package/docs/guide/deploying.md +99 -0
  85. package/docs/guide/getting-started.md +112 -0
  86. package/docs/guide/how-it-works.md +268 -0
  87. package/docs/guide/openapi.md +51 -0
  88. package/docs/guide/pwa.md +636 -0
  89. package/docs/guide/testing.md +42 -0
  90. package/docs/guide/typing-the-loaders.md +311 -0
  91. package/docs/guide.md +27 -1391
  92. package/docs/roadmap.md +39 -1
  93. package/docs/troubleshooting.md +402 -11
  94. package/package.json +70 -6
@@ -0,0 +1,636 @@
1
+ # Installable and offline
2
+
3
+ This page covers what an installable React Router app shares, that has no
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)
14
+ - [The worker's contract](#the-workers-contract)
15
+ - [The worker](#the-worker)
16
+ - [What the worker caches](#what-the-worker-caches)
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)
20
+ - [In React](#in-react)
21
+ - [Registering without React](#registering-without-react)
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
+
26
+ ## What the subpaths hold
27
+
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.
31
+
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 |
39
+
40
+ The main entry, `@alxia/react-router`, is not for those: it imports
41
+ `@alxia/core`, which imports `node:*`, and a client bundle should not carry it.
42
+
43
+ ## The worker's contract
44
+
45
+ The worker and the page must agree on the cache names: the worker fills
46
+ them, the page clears them. Write the contract once, in a module both
47
+ import:
48
+
49
+ ```ts
50
+ // file: app/pwa-contract.ts
51
+ import { defineWorkerContract } from '@alxia/react-router/pwa';
52
+
53
+ export const workerContract = defineWorkerContract({ cachePrefix: 'my-app' });
54
+ ```
55
+
56
+ `cachePrefix` is required: the caches the worker owns are named
57
+ `my-app-<kind>-<version>` (the contract makes `precache`, `assets` and
58
+ `static`), and a cache of that shape is swept when it is not current, a
59
+ retired kind included. Two apps on one origin need two prefixes; `app` and
60
+ `app-admin` do not claim each other's. The options are:
61
+
62
+ | option | default | |
63
+ | --- | --- | --- |
64
+ | `cachePrefix` | none | a non-empty name that does not end with `-` |
65
+ | `cacheVersion` | `'v1'` | a name without `-`; bump it when a cache's content changes shape: the next worker deletes the prefix's caches of another version when it activates |
66
+ | `serviceWorkerUrl` | `/service-worker.js` | where the build writes the worker |
67
+ | `offlineUrl` | `/offline.html` | the page served when a navigation fails |
68
+
69
+ The contract gives three cache names, `cacheNames.precache`
70
+ (`my-app-precache-v1`, the name Workbox builds from
71
+ `setCacheNameDetails({ prefix, precache: 'precache', suffix: version })`),
72
+ `cacheNames.assets` and `cacheNames.static`, and two filters over
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):
85
+
86
+ ```ts
87
+ // file: app/service-worker.ts
88
+ import { startWorker } from '@alxia/react-router/pwa/worker';
89
+ import { workerContract } from './pwa-contract';
90
+
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
+ });
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`.
132
+
133
+ ## What the worker caches
134
+
135
+ `cachingFor(request, origin, options?)` answers with one of four words:
136
+
137
+ | answer | the request | the worker |
138
+ | --- | --- | --- |
139
+ | `navigation` | a page load | always the network, never stored; the offline page only when it fails |
140
+ | `immutable` | a hashed bundle | cache-first |
141
+ | `static` | another same-origin script, style, font or image | stale-while-revalidate |
142
+ | `none` | anything else | untouched, left to the browser |
143
+
144
+ `none` is every non-GET, every other origin, a request with an
145
+ `Authorization` header or a query, React Router's `.data` and `/__manifest`,
146
+ the worker's own path, the manifest's, a document or iframe fetch, and any
147
+ path with no static extension. The options add to that, or move the line
148
+ between `immutable` and `static`:
149
+
150
+ | option | default | |
151
+ | --- | --- | --- |
152
+ | `never(path)` | none | paths of the app always answered fresh |
153
+ | `immutable(path)` | `path.startsWith('/assets/')` | which static paths are cache-first. This matches how the client's files under `build/client/assets/` are served, `immutable` for a year. A file you keep unhashed there is cached for a year by the HTTP cache anyway: [more](../troubleshooting.md#an-unhashed-file-under-publicassets-is-cached-for-a-year) |
154
+ | `serviceWorkerUrl` | `SERVICE_WORKER_URL` | never cached. A value here replaces the default |
155
+ | `contract` | none | the app's `WorkerContract`: its `serviceWorkerUrl` is never cached. `serviceWorkerUrl` wins when both are given |
156
+ | `manifestPath` | `/manifest.webmanifest` | never cached. A value here replaces the default |
157
+
158
+ An app that gives its contract a custom `serviceWorkerUrl` passes the
159
+ contract to `cachingFor` (`{ contract }`), or the same value as
160
+ `serviceWorkerUrl`, or the worker's own script is cached like a static file.
161
+
162
+ The decision is on the request; `isStorable(response)` is on the answer, and
163
+ the worker asks both. A response is stored only when it is a full,
164
+ same-origin 200 of a script, style, font or image, that the server marked
165
+ `public` (alxia does on every static file it serves, and a route answering an
166
+ image, such as a user's photo, does not), neither `private` nor `no-store`,
167
+ and that does not vary on `Cookie`, `Authorization` or `*`.
168
+
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
320
+
321
+ A worker precaches the code of every route, so a client navigation offline to
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:
336
+
337
+ ```ts
338
+ // file: scripts/precache.ts
339
+ import { readdirSync, readFileSync } from 'node:fs';
340
+ import {
341
+ assetPrecache,
342
+ parseBrowserManifest,
343
+ precacheList,
344
+ } from '@alxia/react-router/pwa';
345
+
346
+ const assets = 'build/client/assets';
347
+ const files = readdirSync(assets);
348
+ const manifestFile = files.find((file) => /^manifest-.*\.js$/.test(file));
349
+ if (!manifestFile) throw new Error('Run react-router build first.');
350
+
351
+ const manifest = parseBrowserManifest(
352
+ readFileSync(`${assets}/${manifestFile}`, 'utf8'),
353
+ );
354
+
355
+ export const precache = [
356
+ ...precacheList(manifest),
357
+ ...assetPrecache(files, /^body-latin-[\w-]{8}\.woff2$/),
358
+ ];
359
+ ```
360
+
361
+ `precacheList(manifest)` returns the manifest's own file first, then the
362
+ entry's and every route's module, imports and CSS, each once, as
363
+ `{ url, revision: null }`: a hashed name needs no revision. Only `/assets/`
364
+ files named `name-<8 hash characters>.js` or `.css` are kept, so a sprite or
365
+ an unhashed script is left out. `assetPrecache(files, include, base?)` adds
366
+ the files a regex of yours matches, such as fonts, sorted, under `/assets/`
367
+ or `base`. Chunks only a component loads on demand stay out: the cache-first
368
+ route stores them the first time they load.
369
+
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)`.
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,
453
+ `registerServiceWorker(navigator.serviceWorker, onWaiting, options?)`
454
+ registers the worker (`options.url`, `/service-worker.js` by default, and
455
+ `options.scope`, `'/'`) and calls `onWaiting(worker, apply)` for each new
456
+ version that finished installing while an older one controls the page. A
457
+ first install controls nothing, so it offers nothing. `apply(worker)` posts
458
+ `SKIP_WAITING`, and reloads once when the new worker takes control. When the
459
+ tab comes back to the foreground the registration checks for an update, as a
460
+ long-lived page would otherwise only on its next full load. Run it after the
461
+ page has loaded, so it never competes with it:
462
+
463
+ ```ts
464
+ // file: app/register.ts
465
+ import {
466
+ afterLoad,
467
+ createUpdateNotice,
468
+ registerServiceWorker,
469
+ sessionStore,
470
+ } from '@alxia/react-router/pwa';
471
+
472
+ export const notice = createUpdateNotice(sessionStore);
473
+
474
+ export function registerWorker() {
475
+ if (!('serviceWorker' in navigator)) return;
476
+ afterLoad(() => {
477
+ registerServiceWorker(navigator.serviceWorker, (worker, apply) =>
478
+ notice.offer(() => apply(worker)),
479
+ ).catch(console.error);
480
+ });
481
+ }
482
+ ```
483
+
484
+ With several tabs open, a tab that did not click "Reload" never reloads on
485
+ its own (a form may be half filled); once another tab has activated the
486
+ worker, its "Reload" reloads at once, and a double click reloads once.
487
+ `registerServiceWorker` is made of two parts you can use alone:
488
+ `watchForUpdates(registration, container, onWaiting)` finds each waiting
489
+ worker, and `createUpdater(container, reload?)` returns the `apply` that posts
490
+ `SKIP_WAITING` and reloads once.
491
+
492
+ Call it from a browser-only effect, and only in production: a worker under a
493
+ dev server caches the code you are editing.
494
+
495
+ `createUpdateNotice(storage, key?)` is the state a banner reads. `offer(apply)`
496
+ says a version waits; `current()` returns `{ reload, later }`, the same object
497
+ until it changes, or `undefined`; `subscribe(listener)` returns the function
498
+ that stops listening. These are the three arguments of React's
499
+ `useSyncExternalStore`, so a hook is `useSyncExternalStore(notice.subscribe,
500
+ notice.current, () => undefined)`. `later()` hides the notice for the tab's
501
+ session: it is written under `key` (`UPDATE_LATER_KEY`, `alxia.update-later`,
502
+ by default) in `storage()`. `sessionStore` is `sessionStorage` or nothing, and
503
+ a storage that is missing or throws (private mode) only hides the notice on
504
+ that page. `storage()` may return any object with `getItem(key)` and
505
+ `setItem(key, value)`, such as a fake in a spec or another storage
506
+ (`NoticeStorage`). The notice and the registration touch no global at load, and
507
+ neither imports React.
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
+
550
+ ## The web app manifest (and why the language travels in `?lang=`)
551
+
552
+ A browser fetches the manifest without cookies, so a `language` cookie never
553
+ reaches it. The page names its language in the query, and the server reads it:
554
+
555
+ ```tsx excerpt
556
+ import { manifestHref } from '@alxia/react-router/pwa';
557
+
558
+ export function links() {
559
+ return [{ rel: 'manifest', href: manifestHref('fr') }];
560
+ }
561
+ ```
562
+
563
+ ```ts
564
+ // file: app/server.ts
565
+ import { createServer } from '@alxia/react-router';
566
+ import {
567
+ MANIFEST_HEADERS,
568
+ MANIFEST_PATH,
569
+ manifestLanguage,
570
+ webAppManifest,
571
+ } from '@alxia/react-router/pwa';
572
+
573
+ const LANGUAGES = ['en', 'fr'] as const;
574
+
575
+ export default createServer({
576
+ configure: (app) =>
577
+ app.get(MANIFEST_PATH, ({ request, reply }) => {
578
+ const lang = manifestLanguage(new URL(request.url), LANGUAGES, 'en');
579
+ const manifest = webAppManifest({
580
+ name: 'My app',
581
+ description: lang === 'fr' ? 'Mon application' : 'My app',
582
+ lang,
583
+ themeColor: '#1e40af',
584
+ backgroundColor: '#ffffff',
585
+ categories: ['productivity'],
586
+ });
587
+ return reply(200, JSON.stringify(manifest), { headers: MANIFEST_HEADERS });
588
+ }),
589
+ });
590
+ ```
591
+
592
+ `manifestLanguage(url, supported, fallback)` returns `?lang=` when it is one
593
+ of `supported`, else `fallback`. The fallback is a supported language that
594
+ the app resolves from the request's `Accept-Language` (the example passes
595
+ `'en'`; an app with a language negotiator passes its result), never from the
596
+ cookie, because `MANIFEST_HEADERS` says `Vary: Accept-Language`. Those
597
+ headers also make it cacheable for an hour and give it a policy that loads
598
+ nothing: the pages' policy carries a nonce, and a nonce never goes in a
599
+ response a cache may keep.
600
+
601
+ `webAppManifest(options)` takes the texts and colours you translate and
602
+ choose, and keeps the rest the same in every language, so switching language
603
+ never makes a second app:
604
+
605
+ | option | default | |
606
+ | --- | --- | --- |
607
+ | `name`, `description`, `lang`, `themeColor`, `backgroundColor` | none | required |
608
+ | `shortName` | `name` | |
609
+ | `dir` | `'ltr'` | |
610
+ | `categories` | left out | |
611
+ | `icons` | `MINIMAL_2023_ICONS` | the four files `@vite-pwa/assets-generator`'s `minimal2023Preset` writes in `public/`: 64, 192, 512 and a maskable 512. A browser needs a 192 and a 512 to offer the install |
612
+ | `id`, `startUrl`, `scope` | `'/'` | |
613
+ | `display` | `'standalone'` | |
614
+
615
+ Serve the manifest at `MANIFEST_PATH`, or at a path of your own that you give
616
+ both `manifestHref(language, path)` and `cachingFor`'s `manifestPath`. The
617
+ icons' files are yours: test that each `src` exists in `public/`.
618
+
619
+ ## Clearing at sign-out
620
+
621
+ What the caches hold is static and public by design, and clearing them at
622
+ sign-out leaves nothing behind anyway. `clearAppCaches(contract)` deletes the
623
+ caches the worker filled but the offline page and the app's code (the current
624
+ precache), and does nothing where Cache Storage does not exist (the server,
625
+ an old browser):
626
+
627
+ ```ts
628
+ // file: app/sign-out.ts
629
+ import { clearAppCaches } from '@alxia/react-router/pwa';
630
+ import { workerContract } from './pwa-contract';
631
+
632
+ export async function signOut() {
633
+ await fetch('/sign-out', { method: 'POST' });
634
+ await clearAppCaches(workerContract);
635
+ }
636
+ ```
@@ -0,0 +1,42 @@
1
+ # Testing
2
+
3
+ This page covers testing the app in process, with no server.
4
+
5
+ `server.create(wiring)` makes the app in process, from a build: import
6
+ the server, and pass it React Router's build, which
7
+ `build/server/index.js` holds. The test needs Bun's types:
8
+ `bun add -d @types/bun`, and `"bun"` in the tsconfig's `types`.
9
+
10
+ ```ts
11
+ // app/server.test.ts
12
+ import { expect, test } from 'bun:test';
13
+ import type { ServerBuild } from 'react-router';
14
+ import server from './server';
15
+
16
+ const BROWSER = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
17
+
18
+ test('the home page renders', async () => {
19
+ // After `bun run build`: React Router's build is inside the server build.
20
+ const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
21
+ const app = server.create({ build, client: new URL('../build/client', import.meta.url) });
22
+ const response = await app.request('/', { headers: { 'user-agent': BROWSER } });
23
+ expect(response.status).toBe(200);
24
+ });
25
+ ```
26
+
27
+ Or drive the built app itself, the default export of
28
+ `build/server/index.js`: importing it starts no server. Or run it, as
29
+ [the example's spec](https://github.com/softistx/alxia/blob/develop/examples/react-router/app/server.spec.ts)
30
+ does: `bun build/server/index.js` with `PORT=0`, then read the URL from
31
+ its `alxia listening on <url>` line.
32
+
33
+ Send a browser's user agent. `isbot('Bun/1.4.2')` is true, and React
34
+ Router's entry waits for the whole page before it answers a bot, so a test
35
+ with Bun's own user agent never sees a page stream. An index route's action
36
+ is `POST /?index`, not `POST /`.
37
+
38
+ `app.request` runs without a server, so a loader's `alxiaOf(context).server`
39
+ is `undefined` there, and a `publish` it guards with `?.` sends nothing.
40
+ To test one, listen on a free port, `app.listen({ port: 0 })`, open the
41
+ socket, then post to the action (`/todos.data`, the single-fetch URL, for
42
+ its data rather than the page).