@ultimat3/pwa 22.3.1 → 22.3.3

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/CLAUDE.md CHANGED
@@ -15,7 +15,10 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
15
15
  | A generated header | names something that regenerates the file. `sw.js` says `regenerate: x build`, and `As of 2026-08` a build really writes it (#390); it named the CALL for as long as none did. |
16
16
  | What is WIRED, and what is not | **every generator here has a caller, `As of 2026-08`.** `generateWebManifest`, `renderThemeColorMeta`, `planIcons`, `appleTouchLinks` and `BuiltinImagePipeline` through `pwa-artifacts.ts` / `dev-assets.ts` (#362); `generateServiceWorker` — and with it `buildPrecacheManifest`, `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` — through `sw-artifacts.ts` (#390). So `x dev`, the container and the static export emit `manifest.webmanifest`, `sw.js` and `x-sw-register.js` alike. The worker half landed a release later because a bad `sw.js` is sticky: it waited on a real browser check (`packages/cli/e2e/service-worker.e2e.test.ts`), which nothing in the gate could run until #400. **`pwa.push` is the one capability still unwired** — the generator emits a push handler only when a VAPID key comes with it and there is no `pwa.vapid` config key, so `x build` reports it as a `serviceWorkerWarnings` entry rather than dropping it in silence. |
17
17
  | Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
18
- | Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. |
18
+ | Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. Every row is `network-first` `As of 22.3.2`: every rule is a DOCUMENT, and `static: 'cache-first'` / `isr`,`stream: 'stale-while-revalidate'` answered online navigations with the old deploy's HTML (and so its old hashed assets) until Shift+F5. Precache ≠ cache-first — the precached copy is the offline answer. |
19
+ | Worker update | `self.skipWaiting()` in `install`, `As of 22.3.2` — worker-side, because the browsers that must converge run a 22.3.1 worker and register script that never post `skip-waiting`. Safe for an open tab: the worker routes no chunk. Never a reload — the next navigation is fresh. The `skip-waiting` message stays. |
20
+ | Runtime assets | `ServiceWorkerConfig.runtimeAssets` prefixes (`/islands/`) are `cache-first` runtime rules AHEAD of every page rule (`assetRules`, `route-rules.ts`), and go to the network as the browser asked — no build-id header, because a classic script is a `no-cors` request whose headers a worker may not extend. The precache names only what a precached page boots (the CLI chooses: `sw-precache-plan.ts`); all 43 islands (~1 MB) were precached on a first anonymous visit until 22.3.3. |
21
+ | Locales | the worker matches pathnames and the server strips `/en/` before it matches, so the CALLER spells every route once per routed locale (`@ultimat3/cli`'s `sw-precache-plan.ts`). `localePrefixes` is only for the offline document: a navigation under `/en/` tries `/en` + `OFFLINE_DOC` first. |
19
22
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
20
23
  | A truncation of author text | CODE POINTS, never `slice`. `short_name` falls back to the first 12 of `name`, and a UTF-16 `slice` cuts inside a surrogate pair — the lone half encodes as U+FFFD, so an app named with an emoji got a home-screen label ending in a replacement character. `Array.from(name).slice(0, n).join('')` is the form. |
21
24
  | Declared and never wired | `ServiceWorkerConfig.shellUrl` / `shellRevision` / `shellBytes` and `PwaRoute.dataUrl`. No caller in the framework's build path sets any of them, so `precache.ts`'s `reason: 'shell'` and `reason: 'route-data'` branches are reachable only from a hand-built `generateServiceWorker` call. Each doc comment now says so; the shell trio's said "precached for every `spa` route" while `spa` was already deleted from `RENDER_MODES`. **Kept, not deleted** — a public field is a major — and carried as candidates for the next major's declared-and-never-wired half. `revision` and `bytes` are the pair that IS fed, by the CLI's prerender pass. |
@@ -33,7 +36,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
33
36
  | `AppUpdateAvailable` | declares exactly the fields the emitted `activate` block posts, and no more. `version-skew.test.ts` reads the literal back out of the generated source and compares it to a fixture typed `Required<AppUpdateAvailable>` — a field added to the interface stops compiling, a field added to the worker fails the assertion. It declared five and posted two until 9.0.0; the three extra described a forced reload nothing performed. |
34
37
  | Forced reload | **not a capability of this package.** `updateSignal`/`updatePolicy` computed `forced`/`deadlineAt` with no runtime caller and were deleted in 9.0.0. The two runtimes holding both build ids are BELOW this one — `http`'s `ctx.clientBuildId` (tier 2), `sync`'s `update-available` frame (tier 3) — so neither could ever have called into tier 4 to act on one. The app renders its own affordance; the framework never navigates a client. |
35
38
  | A cache copy is never awaited before answering | `Cache.put` reads the whole body, so a strategy that awaited it held a streamed (or slow) document away from the tab until it had ENDED. The emitted strategies hand the copy to `later(wait, …)` — the fetch event's `waitUntil` — and return the response at once. `service-worker-runtime.test.ts` streams a gated body and asserts the page reads the first chunk before it ends; its `StubCache.put` reads the whole body, as the real one does, or the test could not fail. |
36
- | A per-member document | NEVER served from cache while online, and partitioned by principal offline (21.0.0, security). The pages cache was keyed by URL alone, so `/feed` (`stream`, SWR) rendered for kenji answered bruno on a shared browser. The emitted strategies open caches through `openCache`; for `PAGES` that is a facade: a shareable response is stored and read as before, a PRIVATE one (`private`/`no-store`, or carrying `x-ultimate-scope` — `CLIENT_SCOPE_HEADER`, set by `@ultimat3/cli`'s `dev-render.ts` exactly where the scope meta is) is never read from `PAGES` and is kept only in `PAGES~<scope>`, written in the BACKGROUND (a stream's put would otherwise hold the navigation) and read only by `pageOffline`. Storing one principal's page deletes every other partition, so offline answers the most recent member only; private with no scope is not kept at all. `service-worker-runtime.test.ts` runs two members on one URL. |
39
+ | A per-member document | NEVER stored, in any cache, under any rule (22.3.3, security). `openCache` guards every cache a strategy opens: a response that is `private`/`no-store` or carries `x-ultimate-scope` (`CLIENT_SCOPE_HEADER`) is answered and dropped. 21.0.0 kept one in a per-principal partition (`PAGES~<scope>`) for offline, and after sign-out an offline navigation on a shared device showed the previous member's cases (notificado.co). A PERSONAL route (`PwaRoute.personal`: a policy, a `stream`, a `no-store`/`private` cache) is also `network-only` in `strategyFor` and skipped by `buildPrecacheManifest`. `{type:'clear-pages'}` (`CLEAR_PAGES_MESSAGE`) deletes every `x-pages-*` cache — old partitions included — and answers `pages-cleared`. `offline.personalPages: 'last-member'` (an app's explicit opt-in) restores 21.0.0's partition — `pagesCacheSource(mode)` emits one of the two. `service-worker-pages.test.ts` runs both modes. |
37
40
  | The installing page | cached on `activate`. It loaded before the worker controlled it, so no strategy saw it and an `offline: 'runtime'` route was unavailable offline until a second online visit. After `clients.claim()` each controlled window's URL runs through its OWN route's strategy (`warm`) — only rules with `c: 'pages'`, same origin, not `NEVER_CACHE` — never a second caching rule. Best effort; a failed warm-up never fails activation. It runs AFTER activation, never inside its `waitUntil`: a fetch event waits for the worker to finish activating, so a warm-up copying a streamed page's whole body there held the claimed tab's every request — the `/feed` e2e hang. The offline page read (`pageOffline`) awaits it instead. `service-worker-runtime.test.ts` runs it. |
38
41
  | Outbox drain | the worker SENDS NOTHING, `As of 21.0.0`. The outbox is `@ultimat3/realtime`'s and lives in the page (IndexedDB), so the `sync` event — and the `{type:'flush-outbox'}` fallback message, posted by `registerBackgroundSyncSource` wherever `registration.sync` is absent (Safari, Firefox) — both run `drainOutbox`, which posts `OUTBOX_DRAIN_MESSAGE` (`@ultimat3/core`) to EVERY open window client; realtime's listener drains. No client open = the handler resolves and the next load drains; it never fakes work. `/_x/outbox/flush`, `DEFAULT_FLUSH_ENDPOINT`, and `BackgroundSyncOptions` were deleted, and `X_PWA_SYNC_FLUSH_FAILED` / `X_PWA_SYNC_INCOMPLETE` are still registered but thrown by nothing (shipped codes are stable forever): the worker POSTed to a route nothing in the framework ever mounted. The fallback branch lives in the shared message handler, so `CAPABILITY_SW_MARKERS.backgroundSync` carrying `d.type==='flush-outbox'` is what keeps it gated with the `drainOutbox` it calls. `background-sync.test.ts` and `service-worker-runtime.test.ts` run the emitted code and assert the posted messages. |
39
42
  | Outbox | queue, replay, retry and idempotency are all `@ultimat3/realtime`'s. This package only registers the sync trigger and schedules **no retry** — `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23. |
package/README.md CHANGED
@@ -12,11 +12,15 @@ const { source, precache, warnings } = generateServiceWorker(describeRoutes(), c
12
12
 
13
13
  | Render mode | Strategy | Why |
14
14
  |---|---|---|
15
- | `static` | cache-first | built once; the URL's bytes only change on deploy |
16
- | `isr` | stale-while-revalidate | stale is correct by construction, refresh behind |
17
- | `stream` | stale-while-revalidate | shell is reusable, holes come from the network |
15
+ | `static` | network-first | the document names this deploy's hashed assets; the precache is the offline copy |
16
+ | `isr` | network-first | the server's ISR cache is already the stale one; a browser copy only delays a deploy |
17
+ | `stream` | network-first | the shell names this deploy's chunks; the cache is the offline copy |
18
18
  | `ssr` | network-first | freshness is the point; cache is the offline safety net |
19
- | `spa` | cache-first | the shell is identical for every actor |
19
+
20
+ Every rule is a document, so every mode is network-first (22.3.2): `static` was cache-first and
21
+ `isr`/`stream` stale-while-revalidate, and an online visitor got the previous deploy's HTML until a
22
+ hard reload. `offline: 'precache'` still precaches — for offline. The worker calls `skipWaiting()`
23
+ in `install`, so a deploy takes over without every tab closing; it never reloads a page.
20
24
 
21
25
  Overrides: `offline: 'network-only'` forces `network-only`; a per-route `strategy` wins over
22
26
  everything. `api/` routes get no cache rule at all.
@@ -101,7 +105,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
101
105
  | Export | Owns |
102
106
  |---|---|
103
107
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
104
- | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table |
108
+ | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only` |
109
+ | `routeRules`, `assetRules` | the worker's rule list: routes most specific first, runtime asset prefixes (`/islands/`) ahead of them |
110
+ | `CLEAR_PAGES_MESSAGE`, `PAGES_CLEARED_MESSAGE` | `{ type: 'clear-pages' }` — post it to the worker on sign-out; it empties every pages cache and answers `{ type: 'pages-cleared' }` |
105
111
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
106
112
  | `buildId`, `detectSkew`, `retentionPlan` | version skew |
107
113
  | `generateWebManifest` | the manifest + `theme-color` metas for both schemes, from a `WebManifestInput`. Called by `@ultimat3/cli` (`pwa-artifacts.ts`) `As of 2026-08-27`, so `x dev`, the container and the static export all emit `manifest.webmanifest` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "22.3.1",
3
+ "version": "22.3.3",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "22.3.1",
36
- "@ultimat3/seo": "22.3.1"
35
+ "@ultimat3/core": "22.3.3",
36
+ "@ultimat3/seo": "22.3.3"
37
37
  }
38
38
  }
package/src/index.ts CHANGED
@@ -84,6 +84,8 @@ export type {
84
84
  export { generateWebManifest, renderThemeColorMeta, serializeWebManifest } from './manifest';
85
85
  export type { OfflineConfig, OfflineFallback } from './offline-fallback';
86
86
  export { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
87
+ export type { PersonalPages } from './pages-cache-source';
88
+ export { CLEAR_PAGES_MESSAGE, PAGES_CLEARED_MESSAGE } from './pages-cache-source';
87
89
  export type { PrecacheAsset, PrecacheEntry, PrecacheInput, PrecacheManifest } from './precache';
88
90
  export {
89
91
  buildPrecacheManifest,
@@ -105,8 +107,10 @@ export {
105
107
  subscribeSource,
106
108
  subscriptionState,
107
109
  } from './push';
108
- export type { RouteRule, ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker';
109
- export { assertScope, generateServiceWorker, routeRules } from './service-worker';
110
+ export type { RouteRule } from './route-rules';
111
+ export { assetRules, routeRules } from './route-rules';
112
+ export type { ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker';
113
+ export { assertScope, generateServiceWorker } from './service-worker';
110
114
  export type {
111
115
  PwaRoute,
112
116
  StrategyCache,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { PwaNoOfflineFallbackError } from './errors';
8
+ import type { PersonalPages } from './pages-cache-source';
8
9
 
9
10
  export interface OfflineConfig {
10
11
  /** Route path of the offline document, e.g. `/offline`. Required. */
@@ -14,6 +15,8 @@ export interface OfflineConfig {
14
15
  readonly font?: string;
15
16
  /** Requests that must never be answered from a cache (auth, payments). */
16
17
  readonly neverCache?: readonly string[];
18
+ /** A page rendered for someone: `'never'` cached (default), or `'last-member'`. `PersonalPages`. */
19
+ readonly personalPages?: PersonalPages;
17
20
  }
18
21
 
19
22
  export interface OfflineFallback {
@@ -21,6 +24,7 @@ export interface OfflineFallback {
21
24
  readonly image: string | null;
22
25
  readonly font: string | null;
23
26
  readonly neverCache: readonly string[];
27
+ readonly personalPages: PersonalPages;
24
28
  }
25
29
 
26
30
  /**
@@ -80,18 +84,32 @@ export function requireOfflineFallback(
80
84
  image: config.image ?? null,
81
85
  font: config.font ?? null,
82
86
  neverCache: config.neverCache ?? [],
87
+ personalPages: config.personalPages === 'last-member' ? 'last-member' : 'never',
83
88
  };
84
89
  }
85
90
 
86
- /** Emitted into `sw.js`: what to serve when a navigation cannot be answered. */
87
- export function offlineFallbackSource(fallback: OfflineFallback): string {
91
+ /**
92
+ * Emitted into `sw.js`: what to serve when a navigation cannot be answered. `localePrefixes` are
93
+ * the non-default routed locales' URL segments: a navigation under `/en/` tries `/en` + the offline
94
+ * document first, so an English visitor offline is not handed the default locale's page.
95
+ */
96
+ export function offlineFallbackSource(
97
+ fallback: OfflineFallback,
98
+ localePrefixes: readonly string[] = [],
99
+ ): string {
88
100
  const image = fallback.image === null ? 'null' : JSON.stringify(fallback.image);
101
+ const locales = [...new Set(localePrefixes)].sort();
89
102
  return `
90
103
  const OFFLINE_DOC=${JSON.stringify(fallback.document)};
91
104
  const OFFLINE_IMAGE=${image};
105
+ const OFFLINE_LOCALES=${JSON.stringify(locales)};
92
106
  async function offlineFallback(req){
93
107
  const c=await caches.open(PRECACHE);
94
- if(req.mode==='navigate'){const d=await c.match(OFFLINE_DOC);if(d)return d}
108
+ if(req.mode==='navigate'){
109
+ const seg=new URL(req.url).pathname.split('/')[1];
110
+ if(OFFLINE_LOCALES.indexOf(seg)!==-1){const l=await c.match('/'+seg+OFFLINE_DOC);if(l)return l}
111
+ const d=await c.match(OFFLINE_DOC);if(d)return d
112
+ }
95
113
  if(OFFLINE_IMAGE&&req.destination==='image'){const i=await c.match(OFFLINE_IMAGE);if(i)return i}
96
114
  return new Response('',{status:503,statusText:'Offline'})
97
115
  }`.trim();
@@ -1,25 +1,66 @@
1
1
  /**
2
- * The emitted worker's page storage, as source: the facade every strategy opens the pages cache
3
- * through, the per-principal partitions a private document is kept in, the background copy, and
4
- * the offline read. Its own module because `service-worker.ts` is the rest of `sw.js`; it is
5
- * spliced into the fetch block and reads that block's constants (`PAGES`, `SCOPE_HEADER`) and
6
- * `warming` from the activate block. `service-worker-pages.test.ts` runs it.
2
+ * The emitted worker's cache access, as source: the guard every strategy opens a cache through, the
3
+ * background copy, the offline page read and the sign-out purge. Its own module because
4
+ * `service-worker.ts` is the rest of `sw.js`; it is spliced into the fetch block and reads that
5
+ * block's constants (`PAGES`, `PAGES_PREFIX`, `SCOPE_HEADER`) and `warming` from the activate block.
6
+ * `service-worker-pages.test.ts` runs both modes.
7
7
  */
8
8
 
9
- export const PAGES_CACHE_SOURCE = `// The pages cache is a FACADE, because a document rendered for one member is not a page: keyed by
10
- // URL alone, /feed rendered for kenji answered bruno on the same browser. A shareable document is
11
- // stored and read as before. A private one (\`private\`/\`no-store\`, or carrying the scope header)
12
- // is never read from here — so no strategy can answer it from cache while online — and is kept
13
- // only in its principal's own partition, PAGES~<scope>, which offline alone reads. Storing one
14
- // principal's page wipes every other partition: offline answers the most recent member only.
15
- function openCache(cn){return cn===PAGES?pagesCache():caches.open(cn)}
16
- // A cache copy the strategy does NOT await: handed to the event's waitUntil when there is one, and
9
+ /**
10
+ * What a page posts to the active worker on sign-out — `navigator.serviceWorker.controller
11
+ * ?.postMessage({ type: 'clear-pages' })` — to empty every build's pages cache. The worker answers
12
+ * the posting window with `{ type: 'pages-cleared' }` once they are gone.
13
+ */
14
+ export const CLEAR_PAGES_MESSAGE = 'clear-pages';
15
+ export const PAGES_CLEARED_MESSAGE = 'pages-cleared';
16
+
17
+ /** Every build's pages cache starts with this — `cacheNamespace(id, 'pages')` is `x-pages-<id>`. */
18
+ export const PAGES_CACHE_PREFIX = 'x-pages-';
19
+
20
+ /**
21
+ * What the worker does with a page rendered for someone — `pwa.offline.personalPages`.
22
+ *
23
+ * `'never'`, the default `As of 22.3.3`: not cached, in any cache, under any rule, and a personal
24
+ * route is `network-only`. `'last-member'`: 21.0.0's offline-first mode, kept for an app that
25
+ * declares it — never answered from cache online, kept only in the most recent member's own
26
+ * partition for offline. An app that picks it must clear on sign-out (`clear-pages`, or
27
+ * `Clear-Site-Data: "storage"`): on a shared device the partition is the previous member's data.
28
+ */
29
+ export type PersonalPages = 'never' | 'last-member';
30
+
31
+ const SHARED = `// A cache copy the strategy does NOT await: handed to the event's waitUntil when there is one, and
17
32
  // never allowed to reject into nobody — a failed copy costs the copy, not the response.
18
33
  function later(wait,p){const settled=p.catch(()=>{});if(wait)wait(settled);return settled}
19
34
  function isPrivate(r){
20
35
  if(r.headers.has(SCOPE_HEADER))return true;
21
36
  return /(^|,)\\s*(private|no-store)\\s*(,|$)/i.test(r.headers.get('cache-control')||'')
22
37
  }
38
+ // Every cache but the pages cache is GUARDED in both modes: a private response is never stored there.
39
+ function guarded(c){return{match:(req)=>c.match(req),put:async(req,r)=>{if(!isPrivate(r))return c.put(req,r)}}}
40
+ function fallbackFor(rule,req){return rule.c==='pages'?()=>pageOffline(req):()=>offlineFallback(req)}
41
+ // The app's sign-out: every build's pages cache, and every per-member partition.
42
+ async function clearPages(){
43
+ const names=await caches.keys();
44
+ await Promise.all(names.filter((n)=>n.startsWith(PAGES_PREFIX)).map((n)=>caches.delete(n)));
45
+ }`;
46
+
47
+ const NEVER = `// A private response (\`private\`/\`no-store\`, or carrying the scope header) is never stored,
48
+ // whatever the rule. 21.0.0 kept one per member for offline, and after sign-out an offline
49
+ // navigation on a shared device answered the previous member's data. \`no-store\` means what it says.
50
+ function openCache(cn){return caches.open(cn).then(guarded)}
51
+ /** Offline only: the shared copy (a warm-up may have landed it late), then the offline document. */
52
+ async function pageOffline(req){
53
+ await warming;
54
+ const shared=await (await caches.open(PAGES)).match(req);
55
+ if(shared)return shared;
56
+ return offlineFallback(req)
57
+ }`;
58
+
59
+ const LAST_MEMBER = `// personalPages: 'last-member'. The pages cache is a FACADE: a shareable document is stored and
60
+ // read as before; a private one is never read from here — so no strategy answers it from cache
61
+ // while online — and is kept only in its principal's own partition, PAGES~<scope>, which offline
62
+ // alone reads. Storing one principal's page wipes every other partition. clear-pages empties them.
63
+ function openCache(cn){return cn===PAGES?Promise.resolve(pagesCache()):caches.open(cn).then(guarded)}
23
64
  function pagesCache(){return{
24
65
  match:async(req)=>(await caches.open(PAGES)).match(req),
25
66
  put:async(req,r)=>{
@@ -27,9 +68,7 @@ function pagesCache(){return{
27
68
  const scope=r.headers.get(SCOPE_HEADER);
28
69
  // Private with no scope: no principal to file it under, so it is not kept at all.
29
70
  if(scope===null)return;
30
- // NOT awaited by the strategy: a private document is usually a stream, and a strategy awaits
31
- // its put before answering — the navigation would wait for the whole body to be copied into
32
- // the cache. The offline read waits for it instead (pageOffline), which is the only reader.
71
+ // In the background: a private document is usually a stream, and the offline read waits for it.
33
72
  keeping=keeping.then(()=>keepScoped(req,r,PAGES+'~'+scope)).catch(()=>{});
34
73
  }
35
74
  }}
@@ -39,11 +78,10 @@ async function keepScoped(req,r,name){
39
78
  const names=await caches.keys();
40
79
  await Promise.all(names.filter((n)=>n.startsWith(PAGES+'~')&&n!==name).map((n)=>caches.delete(n)));
41
80
  }
42
- /** Offline only: the most recent member's own copy, then the offline document. */
81
+ /** Offline only: the shared copy, then the most recent member's own copy, then the offline document. */
43
82
  async function pageOffline(req){
44
83
  await warming;
45
84
  await keeping;
46
- // The shared copy first — a warm-up may have landed it after the strategy's own read missed.
47
85
  const shared=await (await caches.open(PAGES)).match(req);
48
86
  if(shared)return shared;
49
87
  for(const n of await caches.keys()){
@@ -52,5 +90,7 @@ async function pageOffline(req){
52
90
  if(hit)return hit;
53
91
  }
54
92
  return offlineFallback(req)
55
- }
56
- function fallbackFor(rule,req){return rule.c==='pages'?()=>pageOffline(req):()=>offlineFallback(req)}`;
93
+ }`;
94
+
95
+ export const pagesCacheSource = (mode: PersonalPages): string =>
96
+ `${mode === 'last-member' ? LAST_MEMBER : NEVER}\n${SHARED}`;
package/src/precache.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // budget message on the other side of the build stopped at `kb`, for the same byte count.
9
9
  import { finiteCount, formatBytes } from '@ultimat3/core';
10
10
  import type { PwaRoute } from './strategies';
11
+ import { strategyFor } from './strategies';
11
12
 
12
13
  export interface PrecacheAsset {
13
14
  readonly url: string;
@@ -102,6 +103,10 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
102
103
  if (route.offline !== 'precache') continue;
103
104
  // A dynamic route has no single URL to precache; its instances are runtime-cached.
104
105
  if (route.dynamic === true) continue;
106
+ // A page the worker will never answer from a cache (`personal`, or an explicit network-only
107
+ // override) is not worth an install-time download — and a personal one fetched anonymously
108
+ // at install is a sign-in redirect, stored.
109
+ if (strategyFor(route) === 'network-only') continue;
105
110
  add({
106
111
  url: route.path,
107
112
  revision: route.revision ?? input.buildId,
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The route table as the worker's rule list: which pattern, which strategy, which cache — ordered
3
+ * most specific first, because the emitted `ruleFor` answers the FIRST match. Plus the asset rules
4
+ * that are no route: content-addressed chunks, cached the first time a page asks for one.
5
+ */
6
+
7
+ import { SwScopeInvalidError } from './errors';
8
+ import type { PwaRoute, StrategyName } from './strategies';
9
+ import { strategyFor } from './strategies';
10
+
11
+ export interface RouteRule {
12
+ readonly pattern: string;
13
+ readonly strategy: StrategyName;
14
+ readonly cache: 'precache' | 'runtime' | 'pages';
15
+ /**
16
+ * An asset prefix, not a page: fetched as the browser asked (no build-id header — a classic
17
+ * script is a `no-cors` request, whose headers a worker may not extend) and never warmed.
18
+ */
19
+ readonly asset?: true;
20
+ }
21
+
22
+ const segmentsOf = (path: string): readonly string[] =>
23
+ path.split('/').filter((segment) => segment.length > 0);
24
+
25
+ /**
26
+ * How specifically a path claims a URL: a literal segment beats a `:param`, which beats a `*`.
27
+ * The weights are `@ultimat3/render`'s `compilePattern`, verbatim (100 / 10 / 1), so the service
28
+ * worker and the server rank the same pathname the same way.
29
+ *
30
+ * DUPLICATED, not imported: `render` and `pwa` are both tier 4 and a sideways import is a build
31
+ * error. The shared home is `@ultimat3/core`'s `route-vocabulary.ts` — tier 0, already the owner of
32
+ * `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` for exactly this reason — and moving
33
+ * it there is the follow-up this comment exists to name.
34
+ */
35
+ function specificityOf(path: string): number {
36
+ return segmentsOf(path).reduce((total, segment) => {
37
+ if (segment.startsWith('*')) return total + 1;
38
+ if (segment.startsWith(':')) return total + 10;
39
+ return total + 100;
40
+ }, 0);
41
+ }
42
+
43
+ /**
44
+ * A catch-all is a FALLBACK, and it sorts behind every rule that is not one — a second key, because
45
+ * a sum over segments cannot say it. `/` has no segments and so scores 0, while `/*rest` scores 1:
46
+ * on specificity alone a single root catch-all outranks the home page, and with it every precached
47
+ * entry in the table. The rule this expresses is the one a reader already assumes — a pattern that
48
+ * matches everything answers only what nothing else claimed.
49
+ */
50
+ const hasWildcard = (path: string): boolean =>
51
+ segmentsOf(path).some((segment) => segment.startsWith('*'));
52
+
53
+ /**
54
+ * Ordered MOST SPECIFIC FIRST, because the emitted `ruleFor` returns the first pattern that
55
+ * matches and has no notion of specificity of its own. Sorted alphabetically it did not: `:` (0x3A)
56
+ * and `*` (0x2A) both sort before every letter, so `/posts/:id` shadowed `/posts/new` and a single
57
+ * `/*` catch-all shadowed the entire table — every entry in `PRECACHE_MANIFEST` downloaded at
58
+ * install and then never looked up, and a route the app declared cacheable served `network-only`,
59
+ * which offline is the `/offline` document.
60
+ *
61
+ * The path stays as the tie-break, so the emitted file is still byte-identical for identical input
62
+ * — compared by CODE UNIT, never `localeCompare`, which answers from the runtime's ICU default and
63
+ * collation version: `/Posts` sorted before `/posts` on one machine and after it on the next, for
64
+ * the same route table. The rule `@ultimat3/jobs`' `job.ts` states for `x.manifest.json`, applied
65
+ * to the artifact this file emits.
66
+ */
67
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
68
+
69
+ export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
70
+ return [...routes]
71
+ .filter((route) => route.surface !== 'api')
72
+ .sort(
73
+ (a, b) =>
74
+ Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
75
+ specificityOf(b.path) - specificityOf(a.path) ||
76
+ byCodeUnit(a.path, b.path),
77
+ )
78
+ .map((route) => {
79
+ const strategy = strategyFor(route);
80
+ return {
81
+ pattern: toPattern(route.path),
82
+ strategy,
83
+ cache: cacheFor(route, strategy),
84
+ };
85
+ });
86
+ }
87
+
88
+ function cacheFor(route: PwaRoute, strategy: StrategyName): 'precache' | 'runtime' | 'pages' {
89
+ if (strategy === 'network-only') return 'runtime';
90
+ if (route.offline === 'precache' && route.dynamic !== true) return 'precache';
91
+ return 'pages';
92
+ }
93
+
94
+ /**
95
+ * A trailing slash is dropped before the pattern is built, because the pattern allows one anyway:
96
+ * a locale's home is spelled `/en/`, and `^/en//?$` matched `/en/` and missed `/en`.
97
+ */
98
+ function toPattern(path: string): string {
99
+ const bare = path.replace(/\/+$/, '');
100
+ if (bare === '') return '^/$';
101
+ const body = bare
102
+ .split('/')
103
+ .map((segment) => {
104
+ if (segment.startsWith(':')) return '[^/]+';
105
+ if (segment.startsWith('*')) return '.*';
106
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
+ })
108
+ .join('/');
109
+ return `^${body}/?$`;
110
+ }
111
+
112
+ /**
113
+ * `runtimeAssets` as rules, AHEAD of every page rule: a prefix like `/islands/` names
114
+ * content-addressed chunks, so the first copy is the only copy and `cache-first` is exact. They are
115
+ * what the precache leaves out — every island in the app was precached, so a first anonymous visit
116
+ * downloaded the admin, KYC and payment islands too (~1 MB on notificado.co, 22.3.2).
117
+ */
118
+ export function assetRules(prefixes: readonly string[], scope: string): readonly RouteRule[] {
119
+ return [...new Set(prefixes)].sort(byCodeUnit).map((prefix) => {
120
+ if (!prefix.startsWith(scope)) {
121
+ throw new SwScopeInvalidError(
122
+ `runtime asset prefix ${JSON.stringify(prefix)} is not an absolute path under the ` +
123
+ `worker's scope ${scope}, so no request pathname would ever match it`,
124
+ `name the prefix as the browser requests it, e.g. '${scope}islands/'`,
125
+ );
126
+ }
127
+ return {
128
+ pattern: `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
129
+ strategy: 'cache-first',
130
+ cache: 'runtime',
131
+ asset: true,
132
+ };
133
+ });
134
+ }
@@ -15,13 +15,21 @@ import { isEnabled, resolveCapabilities } from './capabilities';
15
15
  import { SwScopeInvalidError } from './errors';
16
16
  import type { OfflineConfig } from './offline-fallback';
17
17
  import { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
18
- import { PAGES_CACHE_SOURCE } from './pages-cache-source';
18
+ import type { PersonalPages } from './pages-cache-source';
19
+ import {
20
+ CLEAR_PAGES_MESSAGE,
21
+ PAGES_CACHE_PREFIX,
22
+ PAGES_CLEARED_MESSAGE,
23
+ pagesCacheSource,
24
+ } from './pages-cache-source';
19
25
  import type { PrecacheAsset, PrecacheManifest } from './precache';
20
26
  import { buildPrecacheManifest, serializePrecacheManifest } from './precache';
21
27
  import type { VapidConfig } from './push';
22
28
  import { pushSource } from './push';
23
- import type { PwaRoute, StrategyName } from './strategies';
24
- import { STRATEGY_FN_NAMES, STRATEGY_SOURCE, strategyFor } from './strategies';
29
+ import type { RouteRule } from './route-rules';
30
+ import { assetRules, routeRules } from './route-rules';
31
+ import type { PwaRoute } from './strategies';
32
+ import { STRATEGY_FN_NAMES, STRATEGY_SOURCE } from './strategies';
25
33
  import {
26
34
  APP_UPDATE_AVAILABLE,
27
35
  assertBuildId,
@@ -54,17 +62,22 @@ export interface ServiceWorkerConfig {
54
62
  readonly offlineFallbackRevision?: string;
55
63
  readonly offlineFallbackBytes?: number;
56
64
  readonly vapid?: VapidConfig;
65
+ /**
66
+ * Path prefixes of content-addressed assets the precache leaves out (`/islands/`), cached the
67
+ * first time a page asks for one — `cache-first`, runtime cache. See `assetRules`.
68
+ */
69
+ readonly runtimeAssets?: readonly string[];
70
+ /**
71
+ * The URL segments of every non-default routed locale (`['en']` for an `es-co` app). A navigation
72
+ * under `/en/` that finds no network gets `/en` + the offline document when that one is
73
+ * precached, never the default locale's page.
74
+ */
75
+ readonly localePrefixes?: readonly string[];
57
76
  /** Build ids whose caches must survive this activation (see `retentionPlan`). */
58
77
  readonly retainBuildIds?: readonly string[];
59
78
  readonly precacheWarnBytes?: number;
60
79
  }
61
80
 
62
- export interface RouteRule {
63
- readonly pattern: string;
64
- readonly strategy: StrategyName;
65
- readonly cache: 'precache' | 'runtime' | 'pages';
66
- }
67
-
68
81
  export interface ServiceWorkerOutput {
69
82
  readonly source: string;
70
83
  readonly precache: PrecacheManifest;
@@ -98,91 +111,6 @@ export function assertScope(swPath: string, scope: string): void {
98
111
  }
99
112
  }
100
113
 
101
- const segmentsOf = (path: string): readonly string[] =>
102
- path.split('/').filter((segment) => segment.length > 0);
103
-
104
- /**
105
- * How specifically a path claims a URL: a literal segment beats a `:param`, which beats a `*`.
106
- * The weights are `@ultimat3/render`'s `compilePattern`, verbatim (100 / 10 / 1), so the service
107
- * worker and the server rank the same pathname the same way.
108
- *
109
- * DUPLICATED, not imported: `render` and `pwa` are both tier 4 and a sideways import is a build
110
- * error. The shared home is `@ultimat3/core`'s `route-vocabulary.ts` — tier 0, already the owner of
111
- * `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` for exactly this reason — and moving
112
- * it there is the follow-up this comment exists to name.
113
- */
114
- function specificityOf(path: string): number {
115
- return segmentsOf(path).reduce((total, segment) => {
116
- if (segment.startsWith('*')) return total + 1;
117
- if (segment.startsWith(':')) return total + 10;
118
- return total + 100;
119
- }, 0);
120
- }
121
-
122
- /**
123
- * A catch-all is a FALLBACK, and it sorts behind every rule that is not one — a second key, because
124
- * a sum over segments cannot say it. `/` has no segments and so scores 0, while `/*rest` scores 1:
125
- * on specificity alone a single root catch-all outranks the home page, and with it every precached
126
- * entry in the table. The rule this expresses is the one a reader already assumes — a pattern that
127
- * matches everything answers only what nothing else claimed.
128
- */
129
- const hasWildcard = (path: string): boolean =>
130
- segmentsOf(path).some((segment) => segment.startsWith('*'));
131
-
132
- /**
133
- * Ordered MOST SPECIFIC FIRST, because the emitted `ruleFor` returns the first pattern that
134
- * matches and has no notion of specificity of its own. Sorted alphabetically it did not: `:` (0x3A)
135
- * and `*` (0x2A) both sort before every letter, so `/posts/:id` shadowed `/posts/new` and a single
136
- * `/*` catch-all shadowed the entire table — every entry in `PRECACHE_MANIFEST` downloaded at
137
- * install and then never looked up, and a route the app declared cacheable served `network-only`,
138
- * which offline is the `/offline` document.
139
- *
140
- * The path stays as the tie-break, so the emitted file is still byte-identical for identical input
141
- * — compared by CODE UNIT, never `localeCompare`, which answers from the runtime's ICU default and
142
- * collation version: `/Posts` sorted before `/posts` on one machine and after it on the next, for
143
- * the same route table. The rule `@ultimat3/jobs`' `job.ts` states for `x.manifest.json`, applied
144
- * to the artifact this file emits.
145
- */
146
- const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
147
-
148
- export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
149
- return [...routes]
150
- .filter((route) => route.surface !== 'api')
151
- .sort(
152
- (a, b) =>
153
- Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
154
- specificityOf(b.path) - specificityOf(a.path) ||
155
- byCodeUnit(a.path, b.path),
156
- )
157
- .map((route) => {
158
- const strategy = strategyFor(route);
159
- return {
160
- pattern: toPattern(route.path),
161
- strategy,
162
- cache: cacheFor(route, strategy),
163
- };
164
- });
165
- }
166
-
167
- function cacheFor(route: PwaRoute, strategy: StrategyName): 'precache' | 'runtime' | 'pages' {
168
- if (strategy === 'network-only') return 'runtime';
169
- if (route.offline === 'precache' && route.dynamic !== true) return 'precache';
170
- return 'pages';
171
- }
172
-
173
- function toPattern(path: string): string {
174
- if (path === '/') return '^/$';
175
- const body = path
176
- .split('/')
177
- .map((segment) => {
178
- if (segment.startsWith(':')) return '[^/]+';
179
- if (segment.startsWith('*')) return '.*';
180
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
181
- })
182
- .join('/');
183
- return `^${body}/?$`;
184
- }
185
-
186
114
  /** Emit `sw.js`. `routes` are `@ultimat3/render` descriptors, passed as data. */
187
115
  export function generateServiceWorker(
188
116
  routes: readonly PwaRoute[],
@@ -195,7 +123,13 @@ export function generateServiceWorker(
195
123
 
196
124
  const fallback = requireOfflineFallback(config.offline);
197
125
  const capabilities = resolveCapabilities(config.capabilities);
198
- const rules = routeRules(routes);
126
+ // `last-member` routes a personal page by its render mode again — the pages facade partitions it.
127
+ // The precache below still skips it: fetched anonymously at install, it is a sign-in redirect.
128
+ const routed =
129
+ fallback.personalPages === 'last-member'
130
+ ? routes.map((route) => ({ ...route, personal: false }))
131
+ : routes;
132
+ const rules = [...assetRules(config.runtimeAssets ?? [], scope), ...routeRules(routed)];
199
133
 
200
134
  const precache = buildPrecacheManifest({
201
135
  buildId,
@@ -225,10 +159,10 @@ export function generateServiceWorker(
225
159
  `const PRECACHE_MANIFEST=${serializePrecacheManifest(precache)};`,
226
160
  `const ROUTE_RULES=${serializeRules(rules)};`,
227
161
  usedStrategies.map((strategy) => STRATEGY_SOURCE[strategy]).join('\n'),
228
- offlineFallbackSource(fallback),
162
+ offlineFallbackSource(fallback, config.localePrefixes ?? []),
229
163
  INSTALL_BLOCK,
230
164
  activateBlock(),
231
- fetchBlock(),
165
+ fetchBlock(fallback.personalPages),
232
166
  messageBlock(isEnabled(capabilities, 'backgroundSync')),
233
167
  ];
234
168
 
@@ -272,6 +206,7 @@ const SCOPE=${JSON.stringify(scope)};
272
206
  const PRECACHE=${JSON.stringify(cacheNamespace(buildId, 'precache'))};
273
207
  const RUNTIME=${JSON.stringify(cacheNamespace(buildId, 'runtime'))};
274
208
  const PAGES=${JSON.stringify(cacheNamespace(buildId, 'pages'))};
209
+ const PAGES_PREFIX=${JSON.stringify(PAGES_CACHE_PREFIX)};
275
210
  const RETAINED=${JSON.stringify(retainedCaches(retained))};
276
211
  const NEVER_CACHE=${JSON.stringify(neverCache)};
277
212
  const BUILD_HEADER=${JSON.stringify(BUILD_ID_HEADER)};
@@ -291,7 +226,7 @@ function serializeRules(rules: readonly RouteRule[]): string {
291
226
  const rows = rules.map(
292
227
  (rule) =>
293
228
  `{"p":${JSON.stringify(rule.pattern)},"s":${JSON.stringify(STRATEGY_FN_NAMES[rule.strategy])},` +
294
- `"c":${JSON.stringify(rule.cache)}}`,
229
+ `"c":${JSON.stringify(rule.cache)}${rule.asset === true ? ',"a":1' : ''}}`,
295
230
  );
296
231
  return `[${rows.join(',')}]`;
297
232
  }
@@ -308,9 +243,20 @@ function serializeRules(rules: readonly RouteRule[]): string {
308
243
  * and a bundler emits a query of its own: a fixed `?` built `...?locale=en?v=<rev>`, and a single
309
244
  * non-200 for it rejects `addAll`, which rejects the install — no precache, no offline document,
310
245
  * no version-skew header, and a worker that never activates at all.
246
+ *
247
+ * `skipWaiting()` FIRST, `As of 22.3.2`: a new worker activates as soon as its precache is filled,
248
+ * never "once every tab of the origin is closed", which for a returning visitor was days. Nothing
249
+ * else could do it — no page ever posted `skip-waiting` — and it has to be the worker's own call,
250
+ * because the one that must converge is a browser still running a 22.3.1 worker and that release's
251
+ * register script, neither of which will ever ask. Safe for a tab open across the release: this
252
+ * worker routes documents only (content-hashed chunks come from the network and the HTTP cache,
253
+ * never from a cache it deletes on activate), and it never reloads a page — the tab's NEXT
254
+ * navigation is answered network-first by the new worker.
311
255
  */
312
256
  const INSTALL_BLOCK = `
313
257
  self.addEventListener('install',(event)=>{
258
+ // Take over as soon as the precache is in: a waiting worker otherwise waits for every tab to close.
259
+ self.skipWaiting();
314
260
  event.waitUntil((async()=>{
315
261
  const cache=await caches.open(PRECACHE);
316
262
  // Revision is a content hash: unchanged assets are not re-downloaded across deploys.
@@ -387,15 +333,19 @@ const SKEW_STATUS = 409;
387
333
  * is stale, it stops stamping, re-issues the request untagged so the document actually loads, and
388
334
  * tells every window an update is waiting. Skew detection is not lost — it has already fired, and
389
335
  * its whole purpose is to get the client onto the new build, which is what the app now does.
336
+ *
337
+ * Still needed after the install block's `skipWaiting()` (22.3.2): that removes the WAITING half,
338
+ * but the server can roll forward before the browser has fetched the new `sw.js` at all, and a
339
+ * worker from before 22.3.2 is the one answering in between.
390
340
  */
391
- function fetchBlock(): string {
341
+ function fetchBlock(personalPages: PersonalPages): string {
392
342
  return `
393
343
  function ruleFor(url){
394
344
  for(const r of ROUTE_RULES){if(new RegExp(r.p).test(url.pathname))return r}
395
345
  return null
396
346
  }
397
347
  function cacheName(kind){return kind==='precache'?PRECACHE:kind==='pages'?PAGES:RUNTIME}
398
- ${PAGES_CACHE_SOURCE}
348
+ ${pagesCacheSource(personalPages)}
399
349
  const STRATEGIES={cacheFirst:typeof cacheFirst==='function'?cacheFirst:null,
400
350
  networkFirst:typeof networkFirst==='function'?networkFirst:null,
401
351
  staleWhileRevalidate:typeof staleWhileRevalidate==='function'?staleWhileRevalidate:null,
@@ -410,8 +360,9 @@ self.addEventListener('fetch',(event)=>{
410
360
  if(!rule)return;
411
361
  const fn=STRATEGIES[rule.s];
412
362
  if(!fn)return;
413
- // Every proxied request carries the client's build id so the server can detect skew.
414
- const tagged=new Request(req,{headers:withBuild(req.headers)});
363
+ // Every proxied DOCUMENT carries the client's build id so the server can detect skew. An asset
364
+ // goes as the browser asked: a no-cors request's headers cannot be extended.
365
+ const tagged=rule.a?req:new Request(req,{headers:withBuild(req.headers)});
415
366
  event.respondWith(fn(tagged,cacheName(rule.c),fallbackFor(rule,req),(p)=>event.waitUntil(p)).then((res)=>healSkew(req,res)));
416
367
  });
417
368
  function withBuild(headers){
@@ -456,6 +407,7 @@ function messageBlock(backgroundSync: boolean): string {
456
407
  self.addEventListener('message',(event)=>{
457
408
  const d=event.data||{};
458
409
  if(d.type==='skip-waiting')self.skipWaiting();
459
- if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});${flush}
410
+ if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});
411
+ if(d.type===${JSON.stringify(CLEAR_PAGES_MESSAGE)})event.waitUntil(clearPages().then(()=>event.source&&event.source.postMessage({type:${JSON.stringify(PAGES_CLEARED_MESSAGE)}})));${flush}
460
412
  });`.trim();
461
413
  }
package/src/strategies.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The four caching strategies as named functions, plus the render-mode → strategy table.
3
- * You never choose a strategy by hand: the route's render mode already encodes how fresh
4
- * its bytes have to be, so the mapping is derived and the override is the exception.
3
+ * You never choose a strategy by hand: the mapping is derived and the override is the exception.
4
+ * Every rule the worker routes is a DOCUMENT, and a document is network-first in every mode — the
5
+ * cached copy is what offline gets, never what an online visitor gets (22.3.2).
5
6
  */
6
7
 
7
8
  import type { OfflineStrategy, RenderMode } from '@ultimat3/core';
@@ -32,6 +33,12 @@ export interface PwaRoute {
32
33
  readonly dynamic?: boolean;
33
34
  /** Explicit per-route override; wins over the derived strategy. */
34
35
  readonly strategy?: StrategyName;
36
+ /**
37
+ * Rendered FOR someone — a policy, a `stream`, a declared `no-store`/`private` cache (render's
38
+ * `RouteDescriptor.personal`). Such a page is `network-only` and never precached: kept for
39
+ * offline, it answered the previous member's data on a shared device after sign-out.
40
+ */
41
+ readonly personal?: boolean;
35
42
  /**
36
43
  * Content hash of the built HTML — the precache revision. Fed by the CLI's prerender pass; the
37
44
  * `buildId` is the fallback, and it re-downloads every precached page on every deploy.
@@ -52,6 +59,16 @@ export interface PwaRoute {
52
59
  /**
53
60
  * Render mode → runtime strategy. The whole reason `sw.js` is generated, not written.
54
61
  *
62
+ * Every row is `network-first`, `As of 22.3.2`, because every rule this table feeds is a
63
+ * DOCUMENT: `routeRules` projects page routes only, and content-hashed chunks never get a rule —
64
+ * they are `immutable` in the browser's HTTP cache. `static: 'cache-first'` and
65
+ * `isr`/`stream: 'stale-while-revalidate'` answered an ONLINE navigation from the copy the old
66
+ * worker held, and that copy is the old HTML naming the old hashed CSS and islands, so a visitor saw
67
+ * a deploy only after Shift+F5 (measured on notificado.co, 22.3.1). A precached or pages-cached
68
+ * document is the OFFLINE answer, which `networkFirst` already is on a failed fetch; the render
69
+ * mode still decides WHERE that copy lives (`cacheFor`: precache or pages), not whether it is
70
+ * served while the network answers. A per-route `strategy` is still the override.
71
+ *
55
72
  * Two separate things make the closed set hold, and the table needed both. `Record<RenderMode, …>`
56
73
  * over the TIER-0 union is the exhaustiveness check — this was keyed on a hand-copy, which is how
57
74
  * `spa` went on mapping to `cache-first` after `spa` was deleted from the vocabulary: the one
@@ -63,16 +80,16 @@ export interface PwaRoute {
63
80
  * AND an extra key are both build errors.
64
81
  */
65
82
  export const MODE_STRATEGY = Object.freeze<Record<RenderMode, StrategyName>>({
66
- static: 'cache-first',
67
- isr: 'stale-while-revalidate',
83
+ static: 'network-first',
84
+ isr: 'network-first',
68
85
  ssr: 'network-first',
69
- stream: 'stale-while-revalidate',
86
+ stream: 'network-first',
70
87
  });
71
88
 
72
89
  export function strategyFor(route: PwaRoute): StrategyName {
73
90
  if (route.strategy !== undefined) return route.strategy;
74
91
  // `network-only` is a declaration that this URL must never be answered from a cache.
75
- if (route.offline === 'network-only') return 'network-only';
92
+ if (route.offline === 'network-only' || route.personal === true) return 'network-only';
76
93
  return MODE_STRATEGY[route.mode];
77
94
  }
78
95