@alxia/react-router 0.8.1 → 0.9.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 (56) hide show
  1. package/README.md +107 -8
  2. package/dist/chunks/index-zqa30gr7.js +139 -0
  3. package/dist/chunks/index-zqa30gr7.js.map +12 -0
  4. package/dist/pwa/index.d.ts +14 -0
  5. package/dist/pwa/index.d.ts.map +1 -0
  6. package/dist/pwa/index.js +165 -0
  7. package/dist/pwa/index.js.map +14 -0
  8. package/dist/pwa/model/caching.d.ts +46 -0
  9. package/dist/pwa/model/caching.d.ts.map +1 -0
  10. package/dist/pwa/model/clear-app-caches.d.ts +9 -0
  11. package/dist/pwa/model/clear-app-caches.d.ts.map +1 -0
  12. package/dist/pwa/model/manifest.d.ts +80 -0
  13. package/dist/pwa/model/manifest.d.ts.map +1 -0
  14. package/dist/pwa/model/precache-list.d.ts +40 -0
  15. package/dist/pwa/model/precache-list.d.ts.map +1 -0
  16. package/dist/pwa/model/registration.d.ts +39 -0
  17. package/dist/pwa/model/registration.d.ts.map +1 -0
  18. package/dist/pwa/model/start-service-worker.d.ts +24 -0
  19. package/dist/pwa/model/start-service-worker.d.ts.map +1 -0
  20. package/dist/pwa/model/storable.d.ts +8 -0
  21. package/dist/pwa/model/storable.d.ts.map +1 -0
  22. package/dist/pwa/model/update-notice.d.ts +38 -0
  23. package/dist/pwa/model/update-notice.d.ts.map +1 -0
  24. package/dist/pwa/model/worker-contract.d.ts +61 -0
  25. package/dist/pwa/model/worker-contract.d.ts.map +1 -0
  26. package/dist/pwa/react/index.d.ts +9 -0
  27. package/dist/pwa/react/index.d.ts.map +1 -0
  28. package/dist/pwa/react/index.js +75 -0
  29. package/dist/pwa/react/index.js.map +13 -0
  30. package/dist/pwa/react/use-online.d.ts +6 -0
  31. package/dist/pwa/react/use-online.d.ts.map +1 -0
  32. package/dist/pwa/react/use-service-worker.d.ts +33 -0
  33. package/dist/pwa/react/use-service-worker.d.ts.map +1 -0
  34. package/dist/pwa/react/use-update-notice.d.ts +14 -0
  35. package/dist/pwa/react/use-update-notice.d.ts.map +1 -0
  36. package/dist/pwa/server/index.d.ts +7 -0
  37. package/dist/pwa/server/index.d.ts.map +1 -0
  38. package/dist/pwa/server/index.js +18 -0
  39. package/dist/pwa/server/index.js.map +10 -0
  40. package/dist/pwa/server/no-cache.d.ts +22 -0
  41. package/dist/pwa/server/no-cache.d.ts.map +1 -0
  42. package/docs/README.md +12 -3
  43. package/docs/guide/beside-the-pages.md +178 -0
  44. package/docs/guide/csp-nonce.md +100 -0
  45. package/docs/guide/customising-the-server.md +292 -0
  46. package/docs/guide/deploying.md +99 -0
  47. package/docs/guide/getting-started.md +112 -0
  48. package/docs/guide/how-it-works.md +262 -0
  49. package/docs/guide/openapi.md +51 -0
  50. package/docs/guide/pwa.md +475 -0
  51. package/docs/guide/testing.md +42 -0
  52. package/docs/guide/typing-the-loaders.md +311 -0
  53. package/docs/guide.md +27 -1391
  54. package/docs/roadmap.md +25 -1
  55. package/docs/troubleshooting.md +194 -8
  56. package/package.json +26 -4
@@ -0,0 +1,475 @@
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, which requests the worker caches, which
5
+ responses it may store, the precache list read from a build, registering the
6
+ worker and offering an update, the web app manifest, and the cleaning at
7
+ sign-out. The code is in `@alxia/react-router/pwa`, its React hooks in
8
+ `@alxia/react-router/pwa/react`, the server's `noCache` in
9
+ `@alxia/react-router/pwa/server`, and every app-specific name (the cache
10
+ prefix, the paths never cached) is an option, so two apps never share a
11
+ default.
12
+
13
+ The worker itself (its Workbox strategies) and the build that writes it stay
14
+ the app's for now: the [roadmap](../roadmap.md#next) lists them.
15
+
16
+ - [What the subpath holds](#what-the-subpath-holds)
17
+ - [The worker's contract](#the-workers-contract)
18
+ - [What the worker caches](#what-the-worker-caches)
19
+ - [The precache list from a build](#the-precache-list-from-a-build)
20
+ - [Registering the worker and offering an update](#registering-the-worker-and-offering-an-update)
21
+ - [The web app manifest (and why the language travels in `?lang=`)](#the-web-app-manifest-and-why-the-language-travels-in-lang)
22
+ - [In React](#in-react)
23
+ - [Clearing at sign-out](#clearing-at-sign-out)
24
+ - [Serving the worker's script](#serving-the-workers-script)
25
+
26
+ ## What the subpath holds
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 |
38
+
39
+ `@alxia/react-router/pwa/react` is the page's hooks and needs `react`, an
40
+ optional peer of the package (`^19`): its only package import is `react`, and
41
+ it shares no chunk with the main entry, so a page never loads
42
+ core for it (`boundary.spec.ts` checks both). Keep it out of the worker and
43
+ the build script, which would bundle React.
44
+
45
+ `@alxia/react-router/pwa/server` is `noCache` and needs `@alxia/core`, a peer
46
+ the package already has: its only package import is `@alxia/core`
47
+ (`boundary.spec.ts` checks it). It is for the server file alone.
48
+
49
+ The main entry, `@alxia/react-router`, is not for those: it imports
50
+ `@alxia/core`, which imports `node:*`, and a client bundle should not carry it.
51
+
52
+ ## The worker's contract
53
+
54
+ The worker and the page must agree on the cache names: the worker fills
55
+ them, the page clears them. Write the contract once, in a module both
56
+ import:
57
+
58
+ ```ts
59
+ // file: app/pwa-contract.ts
60
+ import { defineWorkerContract } from '@alxia/react-router/pwa';
61
+
62
+ export const workerContract = defineWorkerContract({ cachePrefix: 'my-app' });
63
+ ```
64
+
65
+ `cachePrefix` is required: the caches the worker owns are named
66
+ `my-app-<kind>-<version>` (the contract makes `precache`, `assets` and
67
+ `static`), and a cache of that shape is swept when it is not current, a
68
+ retired kind included. Two apps on one origin need two prefixes; `app` and
69
+ `app-admin` do not claim each other's. The options are:
70
+
71
+ | option | default | |
72
+ | --- | --- | --- |
73
+ | `cachePrefix` | none | a non-empty name that does not end with `-` |
74
+ | `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 |
75
+ | `serviceWorkerUrl` | `/service-worker.js` | where the build writes the worker |
76
+ | `offlineUrl` | `/offline.html` | the page served when a navigation fails |
77
+
78
+ The contract gives three cache names, `cacheNames.precache`
79
+ (`my-app-precache-v1`, the name Workbox builds from
80
+ `setCacheNameDetails({ prefix, precache: 'precache', suffix: version })`),
81
+ `cacheNames.assets` and `cacheNames.static`, and two filters over
82
+ `caches.keys()`:
83
+
84
+ ```ts
85
+ // file: sw/activate.ts
86
+ import { workerContract } from '../app/pwa-contract';
87
+
88
+ export async function deleteObsoleteCaches() {
89
+ const obsolete = workerContract.obsoleteCaches(await caches.keys());
90
+ await Promise.all(obsolete.map((name) => caches.delete(name)));
91
+ }
92
+ ```
93
+
94
+ `obsoleteCaches(names)` returns the prefix's caches that are not current;
95
+ `clearableCaches(names)` returns all of the prefix's but the current
96
+ precache. The module also exports `SERVICE_WORKER_URL`, `OFFLINE_URL` and
97
+ `SKIP_WAITING`, the message a page posts to a waiting worker.
98
+
99
+ ## What the worker caches
100
+
101
+ `cachingFor(request, origin, options?)` answers with one of four words:
102
+
103
+ | answer | the request | the worker |
104
+ | --- | --- | --- |
105
+ | `navigation` | a page load | always the network, never stored; the offline page only when it fails |
106
+ | `immutable` | a hashed bundle | cache-first |
107
+ | `static` | another same-origin script, style, font or image | stale-while-revalidate |
108
+ | `none` | anything else | untouched, left to the browser |
109
+
110
+ `none` is every non-GET, every other origin, a request with an
111
+ `Authorization` header or a query, React Router's `.data` and `/__manifest`,
112
+ the worker's own path, the manifest's, a document or iframe fetch, and any
113
+ path with no static extension. The options add to that, or move the line
114
+ between `immutable` and `static`:
115
+
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
+ | option | default | |
158
+ | --- | --- | --- |
159
+ | `never(path)` | none | paths of the app always answered fresh |
160
+ | `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) |
161
+ | `serviceWorkerUrl` | `SERVICE_WORKER_URL` | never cached. A value here replaces the default |
162
+ | `contract` | none | the app's `WorkerContract`: its `serviceWorkerUrl` is never cached. `serviceWorkerUrl` wins when both are given |
163
+ | `manifestPath` | `/manifest.webmanifest` | never cached. A value here replaces the default |
164
+
165
+ An app that gives its contract a custom `serviceWorkerUrl` passes the
166
+ contract to `cachingFor` (`{ contract }`), or the same value as
167
+ `serviceWorkerUrl`, or the worker's own script is cached like a static file.
168
+
169
+ The decision is on the request; `isStorable(response)` is on the answer, and
170
+ the worker asks both. A response is stored only when it is a full,
171
+ same-origin 200 of a script, style, font or image, that the server marked
172
+ `public` (alxia does on every static file it serves, and a route answering an
173
+ image, such as a user's photo, does not), neither `private` nor `no-store`,
174
+ and that does not vary on `Cookie`, `Authorization` or `*`.
175
+
176
+ ## The precache list from a build
177
+
178
+ A worker precaches the code of every route, so a client navigation offline to
179
+ a route never visited still finds its modules. `react-router build` writes the
180
+ list in `build/client/assets/manifest-*.js`:
181
+
182
+ ```ts
183
+ // file: scripts/precache.ts
184
+ import { readdirSync, readFileSync } from 'node:fs';
185
+ import {
186
+ assetPrecache,
187
+ parseBrowserManifest,
188
+ precacheList,
189
+ } from '@alxia/react-router/pwa';
190
+
191
+ const assets = 'build/client/assets';
192
+ const files = readdirSync(assets);
193
+ const manifestFile = files.find((file) => /^manifest-.*\.js$/.test(file));
194
+ if (!manifestFile) throw new Error('Run react-router build first.');
195
+
196
+ const manifest = parseBrowserManifest(
197
+ readFileSync(`${assets}/${manifestFile}`, 'utf8'),
198
+ );
199
+
200
+ export const precache = [
201
+ ...precacheList(manifest),
202
+ ...assetPrecache(files, /^body-latin-[\w-]{8}\.woff2$/),
203
+ ];
204
+ ```
205
+
206
+ `precacheList(manifest)` returns the manifest's own file first, then the
207
+ entry's and every route's module, imports and CSS, each once, as
208
+ `{ url, revision: null }`: a hashed name needs no revision. Only `/assets/`
209
+ files named `name-<8 hash characters>.js` or `.css` are kept, so a sprite or
210
+ an unhashed script is left out. `assetPrecache(files, include, base?)` adds
211
+ the files a regex of yours matches, such as fonts, sorted, under `/assets/`
212
+ or `base`. Chunks only a component loads on demand stay out: the cache-first
213
+ route stores them the first time they load.
214
+
215
+ ## Registering the worker and offering an update
216
+
217
+ `registerServiceWorker(navigator.serviceWorker, onWaiting, options?)`
218
+ registers the worker (`options.url`, `/service-worker.js` by default, and
219
+ `options.scope`, `'/'`) and calls `onWaiting(worker, apply)` for each new
220
+ version that finished installing while an older one controls the page. A
221
+ first install controls nothing, so it offers nothing. `apply(worker)` posts
222
+ `SKIP_WAITING`, and reloads once when the new worker takes control. When the
223
+ tab comes back to the foreground the registration checks for an update, as a
224
+ long-lived page would otherwise only on its next full load. Run it after the
225
+ page has loaded, so it never competes with it:
226
+
227
+ ```ts
228
+ // file: app/register.ts
229
+ import {
230
+ afterLoad,
231
+ createUpdateNotice,
232
+ registerServiceWorker,
233
+ sessionStore,
234
+ } from '@alxia/react-router/pwa';
235
+
236
+ export const notice = createUpdateNotice(sessionStore);
237
+
238
+ export function startWorker() {
239
+ if (!('serviceWorker' in navigator)) return;
240
+ afterLoad(() => {
241
+ registerServiceWorker(navigator.serviceWorker, (worker, apply) =>
242
+ notice.offer(() => apply(worker)),
243
+ ).catch(console.error);
244
+ });
245
+ }
246
+ ```
247
+
248
+ With several tabs open, a tab that did not click "Reload" never reloads on
249
+ its own (a form may be half filled); once another tab has activated the
250
+ worker, its "Reload" reloads at once, and a double click reloads once.
251
+ `registerServiceWorker` is made of two parts you can use alone:
252
+ `watchForUpdates(registration, container, onWaiting)` finds each waiting
253
+ worker, and `createUpdater(container, reload?)` returns the `apply` that posts
254
+ `SKIP_WAITING` and reloads once.
255
+
256
+ Call it from a browser-only effect, and only in production: a worker under a
257
+ dev server caches the code you are editing.
258
+
259
+ `createUpdateNotice(storage, key?)` is the state a banner reads. `offer(apply)`
260
+ says a version waits; `current()` returns `{ reload, later }`, the same object
261
+ until it changes, or `undefined`; `subscribe(listener)` returns the function
262
+ that stops listening. These are the three arguments of React's
263
+ `useSyncExternalStore`, so a hook is `useSyncExternalStore(notice.subscribe,
264
+ notice.current, () => undefined)`. `later()` hides the notice for the tab's
265
+ session: it is written under `key` (`UPDATE_LATER_KEY`, `alxia.update-later`,
266
+ by default) in `storage()`. `sessionStore` is `sessionStorage` or nothing, and
267
+ a storage that is missing or throws (private mode) only hides the notice on
268
+ that page. `storage()` may return any object with `getItem(key)` and
269
+ `setItem(key, value)`, such as a fake in a spec or another storage
270
+ (`NoticeStorage`). The notice and the registration touch no global at load, and
271
+ neither imports React.
272
+
273
+ ## The web app manifest (and why the language travels in `?lang=`)
274
+
275
+ A browser fetches the manifest without cookies, so a `language` cookie never
276
+ reaches it. The page names its language in the query, and the server reads it:
277
+
278
+ ```tsx
279
+ // file: app/root.tsx
280
+ import { manifestHref } from '@alxia/react-router/pwa';
281
+
282
+ export function links() {
283
+ return [{ rel: 'manifest', href: manifestHref('fr') }];
284
+ }
285
+ ```
286
+
287
+ ```ts
288
+ // file: app/server.ts
289
+ import { createServer } from '@alxia/react-router';
290
+ import {
291
+ MANIFEST_HEADERS,
292
+ MANIFEST_PATH,
293
+ manifestLanguage,
294
+ webAppManifest,
295
+ } from '@alxia/react-router/pwa';
296
+
297
+ const LANGUAGES = ['en', 'fr'] as const;
298
+
299
+ export default createServer({
300
+ configure: (app) =>
301
+ app.get(MANIFEST_PATH, ({ request, reply }) => {
302
+ const lang = manifestLanguage(new URL(request.url), LANGUAGES, 'en');
303
+ const manifest = webAppManifest({
304
+ name: 'My app',
305
+ description: lang === 'fr' ? 'Mon application' : 'My app',
306
+ lang,
307
+ themeColor: '#1e40af',
308
+ backgroundColor: '#ffffff',
309
+ categories: ['productivity'],
310
+ });
311
+ return reply(200, JSON.stringify(manifest), { headers: MANIFEST_HEADERS });
312
+ }),
313
+ });
314
+ ```
315
+
316
+ `manifestLanguage(url, supported, fallback)` returns `?lang=` when it is one
317
+ of `supported`, else `fallback`. The fallback is a supported language that
318
+ the app resolves from the request's `Accept-Language` (the example passes
319
+ `'en'`; an app with a language negotiator passes its result), never from the
320
+ cookie, because `MANIFEST_HEADERS` says `Vary: Accept-Language`. Those
321
+ headers also make it cacheable for an hour and give it a policy that loads
322
+ nothing: the pages' policy carries a nonce, and a nonce never goes in a
323
+ response a cache may keep.
324
+
325
+ `webAppManifest(options)` takes the texts and colours you translate and
326
+ choose, and keeps the rest the same in every language, so switching language
327
+ never makes a second app:
328
+
329
+ | option | default | |
330
+ | --- | --- | --- |
331
+ | `name`, `description`, `lang`, `themeColor`, `backgroundColor` | none | required |
332
+ | `shortName` | `name` | |
333
+ | `dir` | `'ltr'` | |
334
+ | `categories` | left out | |
335
+ | `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 |
336
+ | `id`, `startUrl`, `scope` | `'/'` | |
337
+ | `display` | `'standalone'` | |
338
+
339
+ Serve the manifest at `MANIFEST_PATH`, or at a path of your own that you give
340
+ both `manifestHref(language, path)` and `cachingFor`'s `manifestPath`. The
341
+ icons' files are yours: test that each `src` exists in `public/`.
342
+
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
+ ## Clearing at sign-out
418
+
419
+ What the caches hold is static and public by design, and clearing them at
420
+ sign-out leaves nothing behind anyway. `clearAppCaches(contract)` deletes the
421
+ caches the worker filled but the offline page and the app's code (the current
422
+ precache), and does nothing where Cache Storage does not exist (the server,
423
+ an old browser):
424
+
425
+ ```ts
426
+ // file: app/sign-out.ts
427
+ import { clearAppCaches } from '@alxia/react-router/pwa';
428
+ import { workerContract } from './pwa-contract';
429
+
430
+ export async function signOut() {
431
+ await fetch('/sign-out', { method: 'POST' });
432
+ await clearAppCaches(workerContract);
433
+ }
434
+ ```
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.
@@ -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).