@ultimat3/pwa 22.3.2 → 22.3.4

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
@@ -17,11 +17,13 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
17
17
  | Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
18
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
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. |
20
22
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
21
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. |
22
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. |
23
25
  | Every byte count and every threshold | a whole number of 0 or more, screened with `finiteCount` where it is READ — `buildPrecacheManifest`'s `warnBytes` AND each entry's own `bytes` (shell, route, asset), `createInstallController`'s `minEngagementMs`, `retentionPlan`'s `keep`. `totalBytes > warnBytes` is false when EITHER side is `NaN`, so one asset whose byte count did not arrive as a number took down the install-size warning for every other entry, silently, in the function whose output is otherwise byte-identical per commit; `now() - startedAt < NaN` is false at every instant, so the install prompt fired on first paint; and `Math.max(1, NaN)` is `NaN` with `slice(0, NaN)` `[]`, so a retention plan evicted every deploy including the running one. `Math.max` is not a validator — it propagates. `keep: 0` still means 1 (`version-skew.test.ts` pins it), so the floor is 0 everywhere here. `bun run finite-bounds` is the ratchet. |
24
- | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. `addAll` still does the fetching, because its all-or-nothing failure is what stops a half-populated precache from activating; the install block only re-keys what it stored. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
26
+ | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. Each entry is fetched and `put` on its own, `As of 22.3.4`: `addAll`'s all-or-nothing let one 404 (a stylesheet another pod minted) fail the install of every deploy, so every visitor stayed on the first worker that ever installed (notificado.co). A failed entry costs its offline copy only. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
25
27
  | Precache FETCH | the revision is appended with `?` or `&`, picked per entry. `PrecacheAsset.url` is public API and a bundler emits `?v=<hash>` of its own, so a fixed `?` built `...?locale=en?v=<rev>`; `addAll` is all-or-nothing, so one non-200 there means `install` rejects and the worker never activates at all. |
26
28
  | SW scope | `assertScope` refuses a relative `swPath`. `lastIndexOf('/')` on `sw.js` is `-1`, so the directory was `''` and `scope.startsWith('')` passed for every scope — the check waved through exactly the config most likely to be wrong. |
27
29
  | HTML sinks | one escaper: `escapeAttribute` from `@ultimat3/seo` (tier 1, and the only one reachable — render's `html.ts` is tier 4, sideways). `appleTouchLinks` and `renderThemeColorMeta` interpolate app config into attributes; both escape. Never a second escaper here. |
@@ -34,7 +36,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
34
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. |
35
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. |
36
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. |
37
- | 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. |
38
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. |
39
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. |
40
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
@@ -105,7 +105,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
105
105
  | Export | Owns |
106
106
  |---|---|
107
107
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
108
- | `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' }` |
109
111
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
110
112
  | `buildId`, `detectSkew`, `retentionPlan` | version skew |
111
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.2",
3
+ "version": "22.3.4",
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.2",
36
- "@ultimat3/seo": "22.3.2"
35
+ "@ultimat3/core": "22.3.4",
36
+ "@ultimat3/seo": "22.3.4"
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
  }
@@ -301,13 +236,13 @@ function serializeRules(rules: readonly RouteRule[]): string {
301
236
  * `caches.match(req)` on the bare URL — `ignoreSearch` defaults to `false` — so an entry left
302
237
  * keyed under `?v=<revision>` is a permanent miss: offline serves the fallback document instead
303
238
  * of the page that was precached, and online every precached byte is downloaded a second time.
304
- * `addAll` still does the fetching, because its all-or-nothing failure is what stops a
305
- * half-populated precache from activating; the second pass only re-keys what it stored.
239
+ * Each entry is fetched and stored on its own, `As of 22.3.4`: `addAll`'s all-or-nothing was meant
240
+ * to stop a half-populated precache from activating, and instead one 404 blocked every install
241
+ * of every deploy (notificado.co, 22.3.2) — a stuck worker is worse than a missing offline copy.
306
242
  *
307
243
  * The separator in front of `v=` is chosen per entry, because `PrecacheAsset.url` is public API
308
244
  * and a bundler emits a query of its own: a fixed `?` built `...?locale=en?v=<rev>`, and a single
309
- * non-200 for it rejects `addAll`, which rejects the install — no precache, no offline document,
310
- * no version-skew header, and a worker that never activates at all.
245
+ * non-200 for it cost that entry its offline copy.
311
246
  *
312
247
  * `skipWaiting()` FIRST, `As of 22.3.2`: a new worker activates as soon as its precache is filled,
313
248
  * never "once every tab of the origin is closed", which for a returning visitor was days. Nothing
@@ -324,15 +259,17 @@ self.addEventListener('install',(event)=>{
324
259
  self.skipWaiting();
325
260
  event.waitUntil((async()=>{
326
261
  const cache=await caches.open(PRECACHE);
262
+ // ONE ENTRY AT A TIME, and a failure costs that entry only. addAll was all-or-nothing: one
263
+ // asset answering 404 rejected the install on every deploy, and every visitor stayed on the
264
+ // first worker that ever installed. A missing entry is fetched from the network when asked for.
327
265
  // Revision is a content hash: unchanged assets are not re-downloaded across deploys.
328
266
  // The separator is picked per entry: a precache URL may already carry a query.
329
- const fetched=PRECACHE_MANIFEST.map((e)=>new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
330
- await cache.addAll(fetched);
331
- for(let i=0;i<PRECACHE_MANIFEST.length;i++){
332
- const stored=await cache.match(fetched[i]);
333
- if(stored)await cache.put(new Request(PRECACHE_MANIFEST[i].url),stored);
334
- await cache.delete(fetched[i]);
335
- }
267
+ await Promise.all(PRECACHE_MANIFEST.map(async(e)=>{
268
+ try{
269
+ const r=await fetch(new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
270
+ if(r.ok)await cache.put(new Request(e.url),r);
271
+ }catch(err){}
272
+ }));
336
273
  })());
337
274
  });`.trim();
338
275
 
@@ -403,14 +340,14 @@ const SKEW_STATUS = 409;
403
340
  * but the server can roll forward before the browser has fetched the new `sw.js` at all, and a
404
341
  * worker from before 22.3.2 is the one answering in between.
405
342
  */
406
- function fetchBlock(): string {
343
+ function fetchBlock(personalPages: PersonalPages): string {
407
344
  return `
408
345
  function ruleFor(url){
409
346
  for(const r of ROUTE_RULES){if(new RegExp(r.p).test(url.pathname))return r}
410
347
  return null
411
348
  }
412
349
  function cacheName(kind){return kind==='precache'?PRECACHE:kind==='pages'?PAGES:RUNTIME}
413
- ${PAGES_CACHE_SOURCE}
350
+ ${pagesCacheSource(personalPages)}
414
351
  const STRATEGIES={cacheFirst:typeof cacheFirst==='function'?cacheFirst:null,
415
352
  networkFirst:typeof networkFirst==='function'?networkFirst:null,
416
353
  staleWhileRevalidate:typeof staleWhileRevalidate==='function'?staleWhileRevalidate:null,
@@ -425,8 +362,9 @@ self.addEventListener('fetch',(event)=>{
425
362
  if(!rule)return;
426
363
  const fn=STRATEGIES[rule.s];
427
364
  if(!fn)return;
428
- // Every proxied request carries the client's build id so the server can detect skew.
429
- const tagged=new Request(req,{headers:withBuild(req.headers)});
365
+ // Every proxied DOCUMENT carries the client's build id so the server can detect skew. An asset
366
+ // goes as the browser asked: a no-cors request's headers cannot be extended.
367
+ const tagged=rule.a?req:new Request(req,{headers:withBuild(req.headers)});
430
368
  event.respondWith(fn(tagged,cacheName(rule.c),fallbackFor(rule,req),(p)=>event.waitUntil(p)).then((res)=>healSkew(req,res)));
431
369
  });
432
370
  function withBuild(headers){
@@ -471,6 +409,7 @@ function messageBlock(backgroundSync: boolean): string {
471
409
  self.addEventListener('message',(event)=>{
472
410
  const d=event.data||{};
473
411
  if(d.type==='skip-waiting')self.skipWaiting();
474
- if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});${flush}
412
+ if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});
413
+ if(d.type===${JSON.stringify(CLEAR_PAGES_MESSAGE)})event.waitUntil(clearPages().then(()=>event.source&&event.source.postMessage({type:${JSON.stringify(PAGES_CLEARED_MESSAGE)}})));${flush}
475
414
  });`.trim();
476
415
  }
package/src/strategies.ts CHANGED
@@ -33,6 +33,12 @@ export interface PwaRoute {
33
33
  readonly dynamic?: boolean;
34
34
  /** Explicit per-route override; wins over the derived strategy. */
35
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;
36
42
  /**
37
43
  * Content hash of the built HTML — the precache revision. Fed by the CLI's prerender pass; the
38
44
  * `buildId` is the fallback, and it re-downloads every precached page on every deploy.
@@ -83,7 +89,7 @@ export const MODE_STRATEGY = Object.freeze<Record<RenderMode, StrategyName>>({
83
89
  export function strategyFor(route: PwaRoute): StrategyName {
84
90
  if (route.strategy !== undefined) return route.strategy;
85
91
  // `network-only` is a declaration that this URL must never be answered from a cache.
86
- if (route.offline === 'network-only') return 'network-only';
92
+ if (route.offline === 'network-only' || route.personal === true) return 'network-only';
87
93
  return MODE_STRATEGY[route.mode];
88
94
  }
89
95