@ultimat3/pwa 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/README.md +3 -1
- package/package.json +3 -3
- package/src/index.ts +2 -1
- package/src/service-worker.ts +34 -9
- package/src/strategies.ts +50 -14
package/CLAUDE.md
CHANGED
|
@@ -17,6 +17,8 @@ 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
|
+
| Navigation preload | enabled on `activate`; every emitted strategy reaches the network through `net(req,pre)` (`NETWORK_SOURCE`), never a bare `fetch`, so a navigation is answered by `event.preloadResponse`. The preload carries no build id: `seenBuild` reads skew off the ANSWER. `fromNetwork` is the exported mirror. `service-worker-preload.test.ts` executes it. |
|
|
21
|
+
| Install fill | `PRECACHE_CONCURRENCY` (4) in flight, never `Promise.all` over the manifest: it runs on the first visit against the connection the first click needs. Still one entry per failure. |
|
|
20
22
|
| 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
23
|
| 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. |
|
|
22
24
|
| Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
|
package/README.md
CHANGED
|
@@ -19,7 +19,9 @@ const { source, precache, warnings } = generateServiceWorker(describeRoutes(), c
|
|
|
19
19
|
|
|
20
20
|
Every rule is a document, so every mode is network-first (22.3.2): `static` was cache-first and
|
|
21
21
|
`isr`/`stream` stale-while-revalidate, and an online visitor got the previous deploy's HTML until a
|
|
22
|
-
hard reload. `offline: 'precache'` still precaches — for offline. The worker
|
|
22
|
+
hard reload. `offline: 'precache'` still precaches — for offline. The worker enables navigation
|
|
23
|
+
preload on `activate` and answers a navigation from `event.preloadResponse`, and fills its precache
|
|
24
|
+
`PRECACHE_CONCURRENCY` entries at a time. The worker calls `skipWaiting()`
|
|
23
25
|
in `install`, so a deploy takes over without every tab closing; it never reloads a page.
|
|
24
26
|
|
|
25
27
|
Overrides: `offline: 'network-only'` forces `network-only`; a per-route `strategy` wins over
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/pwa",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.0.0",
|
|
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": "
|
|
36
|
-
"@ultimat3/seo": "
|
|
35
|
+
"@ultimat3/core": "23.0.0",
|
|
36
|
+
"@ultimat3/seo": "23.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/index.ts
CHANGED
|
@@ -110,7 +110,7 @@ export {
|
|
|
110
110
|
export type { RouteRule } from './route-rules';
|
|
111
111
|
export { assetRules, routeRules } from './route-rules';
|
|
112
112
|
export type { ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker';
|
|
113
|
-
export { assertScope, generateServiceWorker } from './service-worker';
|
|
113
|
+
export { assertScope, generateServiceWorker, PRECACHE_CONCURRENCY } from './service-worker';
|
|
114
114
|
export type {
|
|
115
115
|
PwaRoute,
|
|
116
116
|
StrategyCache,
|
|
@@ -120,6 +120,7 @@ export type {
|
|
|
120
120
|
} from './strategies';
|
|
121
121
|
export {
|
|
122
122
|
cacheFirst,
|
|
123
|
+
fromNetwork,
|
|
123
124
|
MODE_STRATEGY,
|
|
124
125
|
networkFirst,
|
|
125
126
|
networkOnly,
|
package/src/service-worker.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { pushSource } from './push';
|
|
|
29
29
|
import type { RouteRule } from './route-rules';
|
|
30
30
|
import { assetRules, routeRules } from './route-rules';
|
|
31
31
|
import type { PwaRoute } from './strategies';
|
|
32
|
-
import { STRATEGY_FN_NAMES, STRATEGY_SOURCE } from './strategies';
|
|
32
|
+
import { NETWORK_SOURCE, STRATEGY_FN_NAMES, STRATEGY_SOURCE } from './strategies';
|
|
33
33
|
import {
|
|
34
34
|
APP_UPDATE_AVAILABLE,
|
|
35
35
|
assertBuildId,
|
|
@@ -158,7 +158,7 @@ export function generateServiceWorker(
|
|
|
158
158
|
constants(buildId, scope, retained, fallback.neverCache),
|
|
159
159
|
`const PRECACHE_MANIFEST=${serializePrecacheManifest(precache)};`,
|
|
160
160
|
`const ROUTE_RULES=${serializeRules(rules)};`,
|
|
161
|
-
usedStrategies.map((strategy) => STRATEGY_SOURCE[strategy]).join('\n'),
|
|
161
|
+
[NETWORK_SOURCE, ...usedStrategies.map((strategy) => STRATEGY_SOURCE[strategy])].join('\n'),
|
|
162
162
|
offlineFallbackSource(fallback, config.localePrefixes ?? []),
|
|
163
163
|
INSTALL_BLOCK,
|
|
164
164
|
activateBlock(),
|
|
@@ -252,7 +252,14 @@ function serializeRules(rules: readonly RouteRule[]): string {
|
|
|
252
252
|
* worker routes documents only (content-hashed chunks come from the network and the HTTP cache,
|
|
253
253
|
* never from a cache it deletes on activate), and it never reloads a page — the tab's NEXT
|
|
254
254
|
* navigation is answered network-first by the new worker.
|
|
255
|
+
*
|
|
256
|
+
* The fill is THROTTLED to `PRECACHE_CONCURRENCY` requests in flight: `Promise.all` over every
|
|
257
|
+
* entry opened them all at once (72 entries, 2.9 MB on notificado.co), on the visitor's first
|
|
258
|
+
* visit, against the same connection their first click needs. Nothing waits on the fill but the
|
|
259
|
+
* worker's own activation, so slower is free.
|
|
255
260
|
*/
|
|
261
|
+
export const PRECACHE_CONCURRENCY = 4;
|
|
262
|
+
|
|
256
263
|
const INSTALL_BLOCK = `
|
|
257
264
|
self.addEventListener('install',(event)=>{
|
|
258
265
|
// Take over as soon as the precache is in: a waiting worker otherwise waits for every tab to close.
|
|
@@ -264,12 +271,18 @@ self.addEventListener('install',(event)=>{
|
|
|
264
271
|
// first worker that ever installed. A missing entry is fetched from the network when asked for.
|
|
265
272
|
// Revision is a content hash: unchanged assets are not re-downloaded across deploys.
|
|
266
273
|
// The separator is picked per entry: a precache URL may already carry a query.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
274
|
+
// ${PRECACHE_CONCURRENCY} AT A TIME: every entry at once competed with the visitor's first click for the connection.
|
|
275
|
+
let i=0;
|
|
276
|
+
const next=async()=>{
|
|
277
|
+
while(i<PRECACHE_MANIFEST.length){
|
|
278
|
+
const e=PRECACHE_MANIFEST[i++];
|
|
279
|
+
try{
|
|
280
|
+
const r=await fetch(new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
|
|
281
|
+
if(r.ok)await cache.put(new Request(e.url),r);
|
|
282
|
+
}catch(err){}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
await Promise.all(Array.from({length:${PRECACHE_CONCURRENCY}},next));
|
|
273
286
|
})());
|
|
274
287
|
});`.trim();
|
|
275
288
|
|
|
@@ -282,6 +295,9 @@ self.addEventListener('activate',(event)=>{
|
|
|
282
295
|
await Promise.all(names.filter((n)=>n.startsWith('x-')&&RETAINED.indexOf(n)===-1)
|
|
283
296
|
.map((n)=>caches.delete(n)));
|
|
284
297
|
await self.clients.claim();
|
|
298
|
+
// Navigation preload: the browser sends a navigation's request WHILE this worker boots, and
|
|
299
|
+
// the fetch handler answers from it (net) — start-up is off every navigation's critical path.
|
|
300
|
+
try{if(self.registration&&self.registration.navigationPreload)await self.registration.navigationPreload.enable()}catch(e){}
|
|
285
301
|
const cs=await self.clients.matchAll({type:'window'});
|
|
286
302
|
for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:BUILD_ID});
|
|
287
303
|
// NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
|
|
@@ -365,8 +381,17 @@ self.addEventListener('fetch',(event)=>{
|
|
|
365
381
|
// Every proxied DOCUMENT carries the client's build id so the server can detect skew. An asset
|
|
366
382
|
// goes as the browser asked: a no-cors request's headers cannot be extended.
|
|
367
383
|
const tagged=rule.a?req:new Request(req,{headers:withBuild(req.headers)});
|
|
368
|
-
|
|
384
|
+
// A navigation's preload is the browser's own request, so it carries no build id: skew is read
|
|
385
|
+
// off the ANSWER instead (seenBuild), which is the new build's document in one round trip.
|
|
386
|
+
const pre=req.mode==='navigate'&&event.preloadResponse?Promise.resolve(event.preloadResponse).then((r)=>{if(r)seenBuild(r);return r}):undefined;
|
|
387
|
+
event.respondWith(fn(tagged,cacheName(rule.c),fallbackFor(rule,req),(p)=>event.waitUntil(p),pre).then((res)=>healSkew(req,res)));
|
|
369
388
|
});
|
|
389
|
+
function seenBuild(res){
|
|
390
|
+
const server=res.headers.get(BUILD_HEADER);
|
|
391
|
+
if(SKEWED||server===null||server===BUILD_ID)return;
|
|
392
|
+
SKEWED=true;
|
|
393
|
+
self.clients.matchAll({type:'window'}).then((cs)=>{for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:server})}).catch(()=>{});
|
|
394
|
+
}
|
|
370
395
|
function withBuild(headers){
|
|
371
396
|
const h=new Headers(headers);
|
|
372
397
|
// Once the server has told this worker it is stale, stamping the id again only earns
|
package/src/strategies.ts
CHANGED
|
@@ -110,6 +110,32 @@ export interface StrategyOptions {
|
|
|
110
110
|
readonly cacheName: string;
|
|
111
111
|
/** Served when the network fails and the cache is empty. */
|
|
112
112
|
readonly fallback?: () => Promise<Response>;
|
|
113
|
+
/**
|
|
114
|
+
* The fetch event's `preloadResponse`, for a navigation: the request the browser started while
|
|
115
|
+
* the worker was still booting. Used INSTEAD of a fetch when it resolves to a response.
|
|
116
|
+
*/
|
|
117
|
+
readonly preload?: Promise<Response | undefined>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The network's answer — the navigation preload when there is one, else a fetch. A preload that
|
|
122
|
+
* rejects or resolves empty (the feature is off, or the request was not a navigation) is not a
|
|
123
|
+
* network failure: the fetch decides that.
|
|
124
|
+
*/
|
|
125
|
+
export async function fromNetwork(
|
|
126
|
+
request: Request,
|
|
127
|
+
env: StrategyEnv,
|
|
128
|
+
options: Pick<StrategyOptions, 'preload'>,
|
|
129
|
+
): Promise<Response> {
|
|
130
|
+
if (options.preload !== undefined) {
|
|
131
|
+
try {
|
|
132
|
+
const early = await options.preload;
|
|
133
|
+
if (early !== undefined) return early;
|
|
134
|
+
} catch {
|
|
135
|
+
// Falls through to the fetch, whose failure is the one the strategy handles.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return env.fetch(request);
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
export async function cacheFirst(
|
|
@@ -130,7 +156,7 @@ export async function networkFirst(
|
|
|
130
156
|
): Promise<Response> {
|
|
131
157
|
const cache = await env.open(options.cacheName);
|
|
132
158
|
try {
|
|
133
|
-
const response = await
|
|
159
|
+
const response = await fromNetwork(request, env, options);
|
|
134
160
|
if (response.ok) await cache.put(request, response.clone());
|
|
135
161
|
return response;
|
|
136
162
|
} catch (error) {
|
|
@@ -149,8 +175,7 @@ export async function staleWhileRevalidate(
|
|
|
149
175
|
): Promise<Response> {
|
|
150
176
|
const cache = await env.open(options.cacheName);
|
|
151
177
|
const hit = await cache.match(request);
|
|
152
|
-
const refresh = env
|
|
153
|
-
.fetch(request)
|
|
178
|
+
const refresh = fromNetwork(request, env, options)
|
|
154
179
|
.then(async (response) => {
|
|
155
180
|
if (response.ok) await cache.put(request, response.clone());
|
|
156
181
|
return response;
|
|
@@ -173,7 +198,7 @@ export async function networkOnly(
|
|
|
173
198
|
options: StrategyOptions,
|
|
174
199
|
): Promise<Response> {
|
|
175
200
|
try {
|
|
176
|
-
return await
|
|
201
|
+
return await fromNetwork(request, env, options);
|
|
177
202
|
} catch (error) {
|
|
178
203
|
if (options.fallback !== undefined) return options.fallback();
|
|
179
204
|
throw error;
|
|
@@ -199,7 +224,7 @@ async function fetchAndStore(
|
|
|
199
224
|
options: StrategyOptions,
|
|
200
225
|
): Promise<Response> {
|
|
201
226
|
try {
|
|
202
|
-
const response = await
|
|
227
|
+
const response = await fromNetwork(request, env, options);
|
|
203
228
|
if (response.ok) await cache.put(request, response.clone());
|
|
204
229
|
return response;
|
|
205
230
|
} catch (error) {
|
|
@@ -221,30 +246,41 @@ async function fallbackOrThrow(options: StrategyOptions): Promise<Response> {
|
|
|
221
246
|
* partitions a per-member document by principal (`service-worker.ts`, `pagesCache`). And the cache
|
|
222
247
|
* copy is NEVER awaited before answering: `Cache.put` reads the whole body, so awaiting it held a
|
|
223
248
|
* streamed document away from the tab until it had ended. It goes to `later(wait, …)` instead —
|
|
224
|
-
* the fetch event's `waitUntil`, which keeps the worker alive for the copy.
|
|
249
|
+
* the fetch event's `waitUntil`, which keeps the worker alive for the copy. The network is `net`
|
|
250
|
+
* (`NETWORK_SOURCE`), never a bare `fetch`: a navigation's preload answers first.
|
|
225
251
|
*/
|
|
226
252
|
export const STRATEGY_SOURCE = Object.freeze<Record<StrategyName, string>>({
|
|
227
|
-
'cache-first': `async function cacheFirst(req,cn,fb,wait){
|
|
253
|
+
'cache-first': `async function cacheFirst(req,cn,fb,wait,pre){
|
|
228
254
|
const c=await openCache(cn);const hit=await c.match(req);if(hit)return hit;
|
|
229
|
-
try{const r=await
|
|
255
|
+
try{const r=await net(req,pre);if(r.ok)later(wait,c.put(req,r.clone()));return r}catch(e){if(fb)return fb();throw e}
|
|
230
256
|
}`,
|
|
231
|
-
'network-first': `async function networkFirst(req,cn,fb,wait){
|
|
257
|
+
'network-first': `async function networkFirst(req,cn,fb,wait,pre){
|
|
232
258
|
const c=await openCache(cn);
|
|
233
|
-
try{const r=await
|
|
259
|
+
try{const r=await net(req,pre);if(r.ok)later(wait,c.put(req,r.clone()));return r}
|
|
234
260
|
catch(e){const hit=await c.match(req);if(hit)return hit;if(fb)return fb();throw e}
|
|
235
261
|
}`,
|
|
236
|
-
'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb,wait){
|
|
262
|
+
'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb,wait,pre){
|
|
237
263
|
const c=await openCache(cn);const hit=await c.match(req);
|
|
238
|
-
const refresh=
|
|
264
|
+
const refresh=net(req,pre).then((r)=>{if(r.ok)later(wait,c.put(req,r.clone()));return r})
|
|
239
265
|
.catch(()=>hit||(fb?fb():Response.error()));
|
|
240
266
|
if(hit){later(wait,refresh);return hit}
|
|
241
267
|
return refresh
|
|
242
268
|
}`,
|
|
243
|
-
'network-only': `async function networkOnly(req,cn,fb,wait){
|
|
244
|
-
try{return await
|
|
269
|
+
'network-only': `async function networkOnly(req,cn,fb,wait,pre){
|
|
270
|
+
try{return await net(req,pre)}catch(e){if(fb)return fb();throw e}
|
|
245
271
|
}`,
|
|
246
272
|
});
|
|
247
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The emitted `fromNetwork`: every strategy source above reaches the network through it. `pre` is
|
|
276
|
+
* the fetch event's `preloadResponse` for a navigation — the request the browser made in parallel
|
|
277
|
+
* with the worker's start-up — and `undefined` for everything else.
|
|
278
|
+
*/
|
|
279
|
+
export const NETWORK_SOURCE = `async function net(req,pre){
|
|
280
|
+
if(pre){try{const r=await pre;if(r)return r}catch(e){}}
|
|
281
|
+
return fetch(req)
|
|
282
|
+
}`;
|
|
283
|
+
|
|
248
284
|
export const STRATEGY_FN_NAMES = Object.freeze<Record<StrategyName, string>>({
|
|
249
285
|
'cache-first': 'cacheFirst',
|
|
250
286
|
'network-first': 'networkFirst',
|