@ultimat3/pwa 19.1.2 → 19.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "19.1.2",
3
+ "version": "19.2.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "19.1.2",
35
- "@ultimat3/seo": "19.1.2"
34
+ "@ultimat3/core": "19.2.0",
35
+ "@ultimat3/seo": "19.2.0"
36
36
  }
37
37
  }
@@ -252,7 +252,8 @@ const RUNTIME=${JSON.stringify(cacheNamespace(buildId, 'runtime'))};
252
252
  const PAGES=${JSON.stringify(cacheNamespace(buildId, 'pages'))};
253
253
  const RETAINED=${JSON.stringify(retainedCaches(retained))};
254
254
  const NEVER_CACHE=${JSON.stringify(neverCache)};
255
- const BUILD_HEADER=${JSON.stringify(BUILD_ID_HEADER)};`.trim();
255
+ const BUILD_HEADER=${JSON.stringify(BUILD_ID_HEADER)};
256
+ let SKEWED=false;`.trim();
256
257
  }
257
258
 
258
259
  function retainedCaches(buildIds: readonly string[]): readonly string[] {
@@ -316,6 +317,30 @@ self.addEventListener('activate',(event)=>{
316
317
  });`.trim();
317
318
  }
318
319
 
320
+ /**
321
+ * The status `@ultimat3/http` answers a stale build id with. Declared here as a number rather
322
+ * than imported: tier-4 packages do not import each other, and the wire status is the contract
323
+ * between them — `X_BUILD_SKEW` maps to 409 in `packages/http/src/error-map.ts`.
324
+ */
325
+ const SKEW_STATUS = 409;
326
+
327
+ /**
328
+ * The fetch block, plus the one escape from a deadlock the skew guard can otherwise create.
329
+ *
330
+ * The guard is sound in both halves and lethal together: this worker stamps its own `BUILD_ID`
331
+ * on every request it proxies, and a server on a newer build answers that with a 409 whose body
332
+ * is a refusal page rather than the app. The refusal page is not the app, so nothing in it posts
333
+ * `skip-waiting`; the replacement worker stays `installed` and waiting, because a waiting worker
334
+ * activates only once every client is released; and a reload re-enters the same worker, which
335
+ * stamps the same id and earns the same 409. Measured on a dev restart: `waiting: "installed"`,
336
+ * `active: "activated"`, and every navigation 409 until the registration was cleared by hand —
337
+ * while the error's own advice was "reload the page".
338
+ *
339
+ * `healSkew` breaks it at the only point still under this worker's control: having been told it
340
+ * is stale, it stops stamping, re-issues the request untagged so the document actually loads, and
341
+ * tells every window an update is waiting. Skew detection is not lost — it has already fired, and
342
+ * its whole purpose is to get the client onto the new build, which is what the app now does.
343
+ */
319
344
  function fetchBlock(): string {
320
345
  return `
321
346
  function ruleFor(url){
@@ -339,10 +364,27 @@ self.addEventListener('fetch',(event)=>{
339
364
  if(!fn)return;
340
365
  // Every proxied request carries the client's build id so the server can detect skew.
341
366
  const tagged=new Request(req,{headers:withBuild(req.headers)});
342
- event.respondWith(fn(tagged,cacheName(rule.c),()=>offlineFallback(req)));
367
+ event.respondWith(fn(tagged,cacheName(rule.c),()=>offlineFallback(req)).then((res)=>healSkew(req,res)));
343
368
  });
344
369
  function withBuild(headers){
345
- const h=new Headers(headers);h.set(BUILD_HEADER,BUILD_ID);return h
370
+ const h=new Headers(headers);
371
+ // Once the server has told this worker it is stale, stamping the id again only earns
372
+ // another refusal. See healSkew below.
373
+ if(!SKEWED)h.set(BUILD_HEADER,BUILD_ID);
374
+ return h
375
+ }
376
+ async function healSkew(req,res){
377
+ if(SKEWED||res.status!==${SKEW_STATUS})return res;
378
+ const server=res.headers.get(BUILD_HEADER);
379
+ if(server===null||server===BUILD_ID)return res;
380
+ // This worker is provably the old build, and it cannot activate its own replacement: a
381
+ // waiting worker takes over only when every client is released, and the refusal page is
382
+ // what the client is now holding, so the app that would post skip-waiting never runs.
383
+ // Answering the request for real is what breaks that loop.
384
+ SKEWED=true;
385
+ const cs=await self.clients.matchAll({type:'window'});
386
+ for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:server});
387
+ return fetch(new Request(req,{headers:withBuild(req.headers)}));
346
388
  }`.trim();
347
389
  }
348
390