@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 +4 -2
- package/README.md +3 -1
- package/package.json +3 -3
- package/src/index.ts +6 -2
- package/src/offline-fallback.ts +21 -3
- package/src/pages-cache-source.ts +60 -20
- package/src/precache.ts +5 -0
- package/src/route-rules.ts +134 -0
- package/src/service-worker.ts +53 -114
- package/src/strategies.ts +7 -1
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. `
|
|
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
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/seo": "22.3.
|
|
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
|
|
109
|
-
export {
|
|
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,
|
package/src/offline-fallback.ts
CHANGED
|
@@ -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
|
-
/**
|
|
87
|
-
|
|
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'){
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/src/service-worker.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
24
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
305
|
-
* half-populated precache from activating
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
${
|
|
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
|
|
429
|
-
|
|
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})
|
|
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
|
|