@ultimat3/pwa 22.14.0 → 22.15.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 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 calls `skipWaiting()`
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": "22.14.0",
3
+ "version": "22.15.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": "22.14.0",
36
- "@ultimat3/seo": "22.14.0"
35
+ "@ultimat3/core": "22.15.0",
36
+ "@ultimat3/seo": "22.15.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,
@@ -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
- 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
- }));
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
- event.respondWith(fn(tagged,cacheName(rule.c),fallbackFor(rule,req),(p)=>event.waitUntil(p)).then((res)=>healSkew(req,res)));
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 env.fetch(request);
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 env.fetch(request);
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 env.fetch(request);
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 fetch(req);if(r.ok)later(wait,c.put(req,r.clone()));return r}catch(e){if(fb)return fb();throw e}
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 fetch(req);if(r.ok)later(wait,c.put(req,r.clone()));return r}
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=fetch(req).then((r)=>{if(r.ok)later(wait,c.put(req,r.clone()));return r})
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 fetch(req)}catch(e){if(fb)return fb();throw e}
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',