Synchronize verified assets incrementally and guard update activation
This commit is contained in:
25
frontend/asset-sync-core.js
Normal file
25
frontend/asset-sync-core.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||||
|
(function(root){
|
||||||
|
'use strict';
|
||||||
|
const CACHE='ytplayer-assets', STATE='/__ytp_asset_state';
|
||||||
|
const url=(path,file)=>path+'?v='+file.h;
|
||||||
|
function paths(m){return [...new Set(Object.values(m.groups).flatMap(g=>g.files))].sort();}
|
||||||
|
function blocking(m){return [...new Set(Object.entries(m.groups).filter(([n,g])=>n==='core'||!g.background).flatMap(([,g])=>g.files))].sort();}
|
||||||
|
function plan(m,keys){const held=new Set(keys);return {missing:paths(m).map(p=>url(p,m.files[p])).filter(u=>!held.has(u)),blocking:blocking(m).map(p=>url(p,m.files[p]))};}
|
||||||
|
function retained(current,previous){const keep=new Set(paths(current).map(p=>url(p,current.files[p])));if(previous)for(const p of paths(previous))if(current.files[p]&¤t.files[p].h!==previous.files[p].h)keep.add(url(p,previous.files[p]));return keep;}
|
||||||
|
async function status(m,cache){let missing=0;for(const p of blocking(m)){const r=await cache.match(url(p,m.files[p]));if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing++;}return {ready:missing===0,missing,version:m.buildTag};}
|
||||||
|
async function syncAssets(m,{cache,fetchFn,concurrency=6,attempts=3}){
|
||||||
|
const missing=[];for(const p of paths(m)){const u=url(p,m.files[p]),r=await cache.match(u);if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing.push(u);}
|
||||||
|
const count=missing.length;let failure;
|
||||||
|
await Promise.all(Array.from({length:Math.min(6,concurrency,Math.max(1,count))},async()=>{
|
||||||
|
while(missing.length&&!failure){const u=missing.shift();let error;
|
||||||
|
for(let i=0;i<attempts;i++){try{const ctl=new AbortController();const timer=setTimeout(()=>ctl.abort(),30000);let r;try{r=await fetchFn(u,{credentials:'same-origin',signal:ctl.signal});}finally{clearTimeout(timer);}if(!r.ok||r.headers.get('X-Asset-Hash')!==u.split('v=')[1])throw Error('Asset hash mismatch: '+u);await cache.put(u,r);error=null;break;}catch(e){error=e;}}
|
||||||
|
if(error)failure=error;
|
||||||
|
}
|
||||||
|
}));if(failure)throw failure;if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');return {refreshed:count,caches:1};
|
||||||
|
}
|
||||||
|
async function state(cache){const r=await cache.match(STATE);return r?await r.json():null;}
|
||||||
|
async function commit(m,cache){if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');const old=await state(cache);const previous=old?.current?.buildTag===m.buildTag?old.previous:old?.current;await cache.put(STATE,new Response(JSON.stringify({current:m,previous}),{headers:{'Content-Type':'application/json'}}));const keep=retained(m,previous);for(const req of await cache.keys()){const u=new URL(req.url);const k=u.pathname+u.search;if(u.searchParams.has('v')&&!keep.has(k))await cache.delete(k);}return {current:m,previous};}
|
||||||
|
const api={CACHE,STATE,url,paths,blocking,plan,retained,status,syncAssets,state,commit};
|
||||||
|
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.AssetSyncCore=api;
|
||||||
|
})(typeof globalThis!=='undefined'?globalThis:this);
|
||||||
9
frontend/asset-sync-core.test.js
Normal file
9
frontend/asset-sync-core.test.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const core=require('./asset-sync-core');
|
||||||
|
const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}});
|
||||||
|
function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}}
|
||||||
|
const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}});
|
||||||
|
test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);});
|
||||||
|
test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);});
|
||||||
|
test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);});
|
||||||
12
frontend/asset-worker.test.js
Normal file
12
frontend/asset-worker.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||||
|
function environment(){
|
||||||
|
const stores=new Map(),listeners={},messages=[],skips=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||||
|
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||||
|
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,crypto:require('node:crypto').webcrypto,console,fetch:async u=>u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':u.split('v=')[1]}}),self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
||||||
|
async function dispatch(t,e={}){let p;listeners[t]({...e,waitUntil:v=>p=v});await p}
|
||||||
|
async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p}
|
||||||
|
return {storage,m,skips,dispatch,request};
|
||||||
|
}
|
||||||
|
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);});
|
||||||
|
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"/flag-ui.js",
|
"/flag-ui.js",
|
||||||
"/async-guard.js",
|
"/async-guard.js",
|
||||||
"/visible-timer.js",
|
"/visible-timer.js",
|
||||||
|
"/asset-sync-core.js",
|
||||||
"/sw-update.js",
|
"/sw-update.js",
|
||||||
"/fingerprint.js",
|
"/fingerprint.js",
|
||||||
"/opfs.js",
|
"/opfs.js",
|
||||||
|
|||||||
@@ -665,6 +665,7 @@
|
|||||||
<script src="p2p-core.js"></script>
|
<script src="p2p-core.js"></script>
|
||||||
<script src="p2p-transfer.js"></script>
|
<script src="p2p-transfer.js"></script>
|
||||||
<script src="async-guard.js"></script>
|
<script src="async-guard.js"></script>
|
||||||
|
<script src="asset-sync-core.js"></script>
|
||||||
<script src="sw-update.js"></script>
|
<script src="sw-update.js"></script>
|
||||||
<script src="related-videos.js"></script>
|
<script src="related-videos.js"></script>
|
||||||
<script src="audio-continuity.js"></script>
|
<script src="audio-continuity.js"></script>
|
||||||
|
|||||||
@@ -57,3 +57,10 @@ test('the selected glass-stage layout includes its controls and panel dependenci
|
|||||||
'/theme-glass-panel.css', '/glass-panel-layout.js',
|
'/theme-glass-panel.css', '/glass-panel-layout.js',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the shared sync core is eager and loads before the update helper',()=>{
|
||||||
|
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||||
|
assert.ok(groups.core.files.includes('/asset-sync-core.js'));
|
||||||
|
assert.ok(html.indexOf('src="asset-sync-core.js"')<html.indexOf('src="sw-update.js"'));
|
||||||
|
assert.ok(listed.has('/asset-sync-core.js'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
* @param {Function} [opts.setTimeout]
|
* @param {Function} [opts.setTimeout]
|
||||||
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
||||||
*/
|
*/
|
||||||
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
async function refreshLegacyShell({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
||||||
const names = (await cachesApi.keys()).filter(isShellCache);
|
const names = (await cachesApi.keys()).filter(isShellCache);
|
||||||
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
|
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
|
||||||
|
|
||||||
@@ -145,6 +145,42 @@
|
|||||||
return { refreshed: urls.size, caches: names.length, resumed };
|
return { refreshed: urls.size, caches: names.length, resumed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshShellInPlace(opts) {
|
||||||
|
const core = root.AssetSyncCore;
|
||||||
|
if (core) {
|
||||||
|
const response = await opts.fetchFn('/api/manifest', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error('Manifest unavailable');
|
||||||
|
const m = await response.json();
|
||||||
|
const cache = await opts.cachesApi.open(core.CACHE);
|
||||||
|
// Only upgraded workers can route the persistent scheme. A rollback
|
||||||
|
// worker explicitly advertises the legacy path through CACHE_STATUS.
|
||||||
|
const reg = root.navigator && await root.navigator.serviceWorker.getRegistration();
|
||||||
|
const worker = reg && (reg.waiting || reg.active);
|
||||||
|
const status = await askCacheStatus(worker);
|
||||||
|
if (status && status.assetSync) {
|
||||||
|
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn});
|
||||||
|
if (!reg.waiting) await core.commit(m,cache);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refreshLegacyShell({ ...opts, isShellCache: n => n !== 'ytplayer-assets' && opts.isShellCache(n) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function playing() { return typeof Player !== 'undefined' && !!Player._wantsPlaying; }
|
||||||
|
function reportPlaying(value = playing()) {
|
||||||
|
const sw = root.navigator && root.navigator.serviceWorker;
|
||||||
|
if (!sw) return;
|
||||||
|
sw.getRegistration().then(reg => {
|
||||||
|
for (const worker of [sw.controller, reg && reg.waiting]) if(worker) worker.postMessage({type:'PLAYING',value});
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
if (root.document && root.navigator && root.navigator.serviceWorker) {
|
||||||
|
if (root.VisibleTimer) root.VisibleTimer.setIntervalWhenVisible(()=>reportPlaying(),1000);
|
||||||
|
for (const event of ['play','pause','ended']) root.document.addEventListener(event,()=>reportPlaying(),true);
|
||||||
|
root.document.addEventListener('visibilitychange',()=>reportPlaying());
|
||||||
|
root.navigator.serviceWorker.addEventListener('controllerchange',()=>reportPlaying());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks a worker whether it has already precached its whole shell.
|
* Asks a worker whether it has already precached its whole shell.
|
||||||
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
||||||
@@ -191,7 +227,14 @@
|
|||||||
* @param {Function} [opts.setTimeout] injectable for tests
|
* @param {Function} [opts.setTimeout] injectable for tests
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, setTimeout: setTimeoutFn }) {
|
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, isPlaying = playing, confirmOverride = root.confirm && root.confirm.bind(root), setTimeout: setTimeoutFn }) {
|
||||||
|
let overridden = false;
|
||||||
|
const guard = () => {
|
||||||
|
if (!isPlaying() || overridden) return true;
|
||||||
|
if (confirmOverride && confirmOverride('Playing - update after this song / when paused. Update now anyway? Playback may stop.')) { overridden = true; return true; }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||||
const waiting = reg && reg.waiting;
|
const waiting = reg && reg.waiting;
|
||||||
|
|
||||||
@@ -209,6 +252,7 @@
|
|||||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
||||||
if (!precached && refreshShell) await refreshShell();
|
if (!precached && refreshShell) await refreshShell();
|
||||||
|
|
||||||
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
reload();
|
reload();
|
||||||
return;
|
return;
|
||||||
@@ -216,7 +260,7 @@
|
|||||||
|
|
||||||
let reloaded = false;
|
let reloaded = false;
|
||||||
const reloadOnce = () => {
|
const reloadOnce = () => {
|
||||||
if (reloaded) return;
|
if (reloaded || (isPlaying() && !overridden)) return;
|
||||||
reloaded = true;
|
reloaded = true;
|
||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
@@ -224,6 +268,7 @@
|
|||||||
// either way; reload on controllerchange, or after a short safety net.
|
// either way; reload on controllerchange, or after a short safety net.
|
||||||
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||||
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
||||||
|
if (overridden) waiting.postMessage({type:'PLAYING',value:false});
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,3 +349,10 @@ test('askCacheStatus returns the worker reply', async () => {
|
|||||||
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
||||||
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playback guard blocks activation and reload unless explicit override is accepted',async()=>{
|
||||||
|
let messages=[],reloads=0;const reg={waiting:{postMessage:m=>messages.push(m)}};
|
||||||
|
await assert.rejects(applyUpdate({reg,container:fakeContainer(),reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>false}),/Playing/);
|
||||||
|
assert.equal(messages.length,0);assert.equal(reloads,0);
|
||||||
|
let playing=false;const container=fakeContainer();await applyUpdate({reg,container,reload:()=>reloads++,isPlaying:()=>playing,askStatus:async()=>({ready:true}),setTimeout:()=>{}});playing=true;container.fireControllerChange();assert.equal(reloads,0);
|
||||||
|
});
|
||||||
|
|||||||
114
frontend/sw.js
114
frontend/sw.js
@@ -27,13 +27,15 @@
|
|||||||
// It changes on every deploy/restart so the cache is busted automatically
|
// It changes on every deploy/restart so the cache is busted automatically
|
||||||
// without any manual version bump.
|
// without any manual version bump.
|
||||||
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
||||||
|
const ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;
|
||||||
|
if (ASSET_SYNC) importScripts('/asset-sync-core.js');
|
||||||
const CACHE = 'ytplayer-' + VERSION;
|
const CACHE = 'ytplayer-' + VERSION;
|
||||||
|
|
||||||
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
||||||
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
||||||
// match this — they aren't versioned and must survive every activate.
|
// match this — they aren't versioned and must survive every activate.
|
||||||
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
||||||
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
|
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts', 'ytplayer-assets']);
|
||||||
const THUMB_CACHE = 'ytplayer-thumbs';
|
const THUMB_CACHE = 'ytplayer-thumbs';
|
||||||
|
|
||||||
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
||||||
@@ -71,6 +73,7 @@ const SHELL = [
|
|||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
'/visible-timer.js',
|
'/visible-timer.js',
|
||||||
|
'/asset-sync-core.js',
|
||||||
'/sw-update.js',
|
'/sw-update.js',
|
||||||
'/fingerprint.js',
|
'/fingerprint.js',
|
||||||
'/opfs.js',
|
'/opfs.js',
|
||||||
@@ -146,7 +149,7 @@ const SHELL = [
|
|||||||
// skipWaiting() is NOT called here — the page activates a waiting worker
|
// skipWaiting() is NOT called here — the page activates a waiting worker
|
||||||
// from "Refresh UI" (see sw-update.js).
|
// from "Refresh UI" (see sw-update.js).
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(precacheShell());
|
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
||||||
@@ -180,6 +183,7 @@ async function precacheShell() {
|
|||||||
// ---- Activate: evict old caches, claim clients, notify about update ----
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
if (ASSET_SYNC) { await activateAssets(); return; }
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
|
|
||||||
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
||||||
@@ -278,13 +282,20 @@ self.addEventListener('fetch', (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// App shell → cache-first, then network, then generic offline fallback
|
// App shell → cache-first, then network, then generic offline fallback
|
||||||
e.respondWith(cacheFirst(request));
|
e.respondWith(cacheFirst(request, e.clientId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Message: handle SKIP_WAITING from the client ----
|
// ---- Message: handle SKIP_WAITING from the client ----
|
||||||
|
const playingClients = new Map(), deferredClients = new Set();
|
||||||
self.addEventListener('message', (e) => {
|
self.addEventListener('message', (e) => {
|
||||||
|
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||||
|
playingClients.set(e.source.id, !!e.data.value);
|
||||||
|
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
|
||||||
|
}
|
||||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
const id = e.source && e.source.id;
|
||||||
|
if (playingClients.get(id)) deferredClients.add(id);
|
||||||
|
else self.skipWaiting();
|
||||||
}
|
}
|
||||||
// "Is your shell already downloaded?" — a waiting worker has precached the
|
// "Is your shell already downloaded?" — a waiting worker has precached the
|
||||||
// whole new build during install, so "Refresh UI" can swap to it instantly
|
// whole new build during install, so "Refresh UI" can swap to it instantly
|
||||||
@@ -293,6 +304,13 @@ self.addEventListener('message', (e) => {
|
|||||||
// storage pressure, so the files are actually checked.
|
// storage pressure, so the files are actually checked.
|
||||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
if (ASSET_SYNC) {
|
||||||
|
let reply = { ready: false, missing: -1, version: VERSION };
|
||||||
|
try { const cache = await caches.open(AssetSyncCore.CACHE); const m = await candidate(cache); if(m) reply = await AssetSyncCore.status(m,cache); } catch {}
|
||||||
|
reply.type = 'CACHE_STATUS'; reply.assetSync = true;
|
||||||
|
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let missing = SHELL.length;
|
let missing = SHELL.length;
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
@@ -311,7 +329,8 @@ self.addEventListener('message', (e) => {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
||||||
async function cacheFirst(request) {
|
async function cacheFirst(request, clientId) {
|
||||||
|
if (ASSET_SYNC) return assetFetch(request, clientId);
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
// Shell files are precached under their plain URL; index.html asks for them as
|
// Shell files are precached under their plain URL; index.html asks for them as
|
||||||
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
||||||
@@ -417,3 +436,88 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
// surface a real network error instead.
|
// surface a real network error instead.
|
||||||
return cached || (await networkFetch) || Response.error();
|
return cached || (await networkFetch) || Response.error();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Installation stores a candidate only. Activation publishes one atomic state record.
|
||||||
|
const candidateKey = '/__ytp_candidate/' + VERSION;
|
||||||
|
async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; }
|
||||||
|
async function installAssets() {
|
||||||
|
const r = await fetch('/api/manifest', { cache: 'no-store' });
|
||||||
|
if (!r.ok) throw Error('Manifest unavailable');
|
||||||
|
const m = await r.json();
|
||||||
|
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||||
|
const freshInstall = !(await AssetSyncCore.state(cache));
|
||||||
|
const fetchAsset = async (url,init) => {
|
||||||
|
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
|
||||||
|
// their HTTP-cache responses, but only if the hash proves current bytes.
|
||||||
|
if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) {
|
||||||
|
const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'});
|
||||||
|
if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r;
|
||||||
|
}
|
||||||
|
return fetch(url,init);
|
||||||
|
};
|
||||||
|
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset });
|
||||||
|
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
||||||
|
}
|
||||||
|
async function migrateLegacy(cache, m) {
|
||||||
|
// Legacy responses lack hash headers. Verify their actual bytes locally before
|
||||||
|
// retaining them; this also preserves old whole-build URLs in already-open tabs.
|
||||||
|
const names = (await caches.keys()).filter(isVersionedShellCache);
|
||||||
|
let previous = null;
|
||||||
|
for (const name of names) {
|
||||||
|
const old = await caches.open(name);
|
||||||
|
const files = {}, legacyURLs = {};
|
||||||
|
for (const req of await old.keys()) {
|
||||||
|
const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname;
|
||||||
|
if (!m.files[p]) continue;
|
||||||
|
const r = await old.match(req);
|
||||||
|
if (!r || !r.ok) continue;
|
||||||
|
const bytes = await r.clone().arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10);
|
||||||
|
const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h);
|
||||||
|
files[p] = { h }; legacyURLs[p] = p + '?v=' + h;
|
||||||
|
await cache.put(legacyURLs[p], new Response(bytes, { headers }));
|
||||||
|
}
|
||||||
|
if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs };
|
||||||
|
}
|
||||||
|
if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous})));
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
async function activateAssets() {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
||||||
|
if (!m) throw Error('Missing candidate manifest');
|
||||||
|
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
||||||
|
const state = await AssetSyncCore.commit(m,cache);
|
||||||
|
state.previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||||
|
await cache.put(AssetSyncCore.STATE,new Response(JSON.stringify(state)));
|
||||||
|
// Legacy deletion occurs strictly after verified commit.
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)));
|
||||||
|
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||||
|
await self.clients.claim();
|
||||||
|
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
||||||
|
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||||
|
}
|
||||||
|
async function assetFetch(request, clientId) {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||||
|
if(!state) return fetch(request);
|
||||||
|
const u = new URL(request.url);
|
||||||
|
let m = state.current;
|
||||||
|
if(u.origin !== self.location.origin) return fetch(request);
|
||||||
|
if(u.searchParams.has('__ytpfresh')) return fetch(request);
|
||||||
|
const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html';
|
||||||
|
if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous;
|
||||||
|
let path = nav ? '/index.html' : u.pathname;
|
||||||
|
let key;
|
||||||
|
if(u.searchParams.has('v') && !nav) {
|
||||||
|
key = u.pathname + u.search;
|
||||||
|
if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key;
|
||||||
|
} else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]);
|
||||||
|
if(key) {
|
||||||
|
const cached = await cache.match(key);
|
||||||
|
const h = new URL(key,self.location.origin).searchParams.get('v');
|
||||||
|
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
|
||||||
|
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') === h) await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
|
||||||
|
}
|
||||||
|
try { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function loadSw(initialCacheKeys, { fetchImpl } = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
|
__ASSET_SYNC__: false,
|
||||||
self: self_,
|
self: self_,
|
||||||
caches: caches_,
|
caches: caches_,
|
||||||
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
||||||
|
|||||||
@@ -62,3 +62,10 @@ migration browser tests use paused playback; already-open playing tabs are never
|
|||||||
automatically reloaded. This explicit legacy exception needs owner clarification
|
automatically reloaded. This explicit legacy exception needs owner clarification
|
||||||
because the brief states the guarantee without an exception. COMMON.md requires
|
because the brief states the guarantee without an exception. COMMON.md requires
|
||||||
QUESTION and stopping for an unanswered user-visible behavior decision.
|
QUESTION and stopping for an unanswered user-visible behavior decision.
|
||||||
|
|
||||||
|
## Owner answer
|
||||||
|
|
||||||
|
Accepted: the legacy timer remains user-triggered by Refresh UI. New pages
|
||||||
|
show a playback guard with an explicit override, report PLAYING to the worker,
|
||||||
|
and the worker defers known-playing requests. Installation never calls
|
||||||
|
skipWaiting. Migration must prove no automatic legacy reload.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_
|
|||||||
})).replace('__BUILD_TAG__', buildTag);
|
})).replace('__BUILD_TAG__', buildTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) {
|
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) {
|
||||||
const bytes = new Map();
|
const bytes = new Map();
|
||||||
const legacy = createHash('sha256');
|
const legacy = createHash('sha256');
|
||||||
function walk(dir, prefix = '') {
|
function walk(dir, prefix = '') {
|
||||||
@@ -65,11 +65,13 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
|
|||||||
// the index meta and SW injected tag, then publish hashes of the final bytes.
|
// the index meta and SW injected tag, then publish hashes of the final bytes.
|
||||||
// All original bytes (including index/SW source) remain inputs via source hashes.
|
// All original bytes (including index/SW source) remain inputs via source hashes.
|
||||||
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
||||||
const canonical = { files: { ...files }, groups, contracts };
|
const canonical = { assetSync, files: { ...files }, groups, contracts };
|
||||||
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
|
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
|
||||||
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
||||||
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
||||||
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag);
|
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag)
|
||||||
|
.replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync))
|
||||||
|
.replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')");
|
||||||
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
|
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
|
||||||
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
|
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
|
||||||
if (index !== null) bytes.set('/index.html', Buffer.from(index));
|
if (index !== null) bytes.set('/index.html', Buffer.from(index));
|
||||||
|
|||||||
@@ -60,3 +60,11 @@ test('rollback retains legacy tag algorithm and relative-only single-tag stampin
|
|||||||
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
|
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
|
||||||
expect(result.index).toContain('href="/app.css"');
|
expect(result.index).toContain('href="/app.css"');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test('sync rollback changes build identity and injected worker byte hash', () => fixture(dir => {
|
||||||
|
writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
|
||||||
|
const on=createAssetManifest(dir),off=createAssetManifest(dir,{assetSync:false});
|
||||||
|
expect(on.sw).toContain('const SYNC = true');expect(off.sw).toContain('const SYNC = false');
|
||||||
|
expect(on.manifest.buildTag).not.toBe(off.manifest.buildTag);
|
||||||
|
expect(off.manifest.files['/sw.js'].h).toBe(hash(off.sw));
|
||||||
|
}));
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function withSaveSlot(fn) {
|
|||||||
// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
|
// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
|
||||||
// rollback retains the historical recursive tag and single-tag URL stamping.
|
// rollback retains the historical recursive tag and single-tag URL stamping.
|
||||||
const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
|
const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
|
||||||
const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG });
|
const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, assetSync: process.env.ASSET_SYNC !== '0', buildTag: process.env.BUILD_TAG });
|
||||||
const BUILD_TAG = assets.manifest.buildTag;
|
const BUILD_TAG = assets.manifest.buildTag;
|
||||||
|
|
||||||
// BUILD_TIME — human-readable "when was this image built". Written by the
|
// BUILD_TIME — human-readable "when was this image built". Written by the
|
||||||
@@ -357,7 +357,8 @@ app.get('/api/manifest', (c) => {
|
|||||||
const headers = { 'Cache-Control': 'no-store', ETag: etag };
|
const headers = { 'Cache-Control': 'no-store', ETag: etag };
|
||||||
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
|
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
|
||||||
if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers });
|
if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers });
|
||||||
return c.json(assets.manifest, 200, headers);
|
const entry = compressedEntry('api-manifest',Buffer.from(JSON.stringify(assets.manifest)), 'application/json; charset=utf-8');
|
||||||
|
return sendCompressed(c, { ...entry, etag }, 'no-store');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/version', (c) =>
|
app.get('/api/version', (c) =>
|
||||||
@@ -2440,13 +2441,8 @@ app.get('/sw.js', (c) => {
|
|||||||
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
||||||
// after which the replacement silently did nothing, the SW version froze,
|
// after which the replacement silently did nothing, the SW version froze,
|
||||||
// and clients never saw another update no matter how many times we deployed.
|
// and clients never saw another update no matter how many times we deployed.
|
||||||
const src = _swSource.replace(
|
const src = assets.sw;
|
||||||
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
|
if (!/typeof __BUILD_TAG__/.test(_swSource)) console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||||
JSON.stringify(BUILD_TAG)
|
|
||||||
);
|
|
||||||
if (src === _swSource) {
|
|
||||||
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
|
||||||
}
|
|
||||||
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
|
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ async function freePort() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(hashing = true) {
|
async function startServer(hashing = true, sync = true) {
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const child = Bun.spawn([process.execPath, serverFile], {
|
const child = Bun.spawn([process.execPath, serverFile], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', ASSET_SYNC: sync ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
});
|
});
|
||||||
const stdout = new Response(child.stdout).text();
|
const stdout = new Response(child.stdout).text();
|
||||||
@@ -168,6 +168,19 @@ describe('static delivery characterization', () => {
|
|||||||
} finally { await stopServer(server); server = await startServer(); }
|
} finally { await stopServer(server); server = await startServer(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manifest compression preserves build ETag and sync mode is injected', async () => {
|
||||||
|
const version=await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
const response=await fetch(`${server.base}/api/manifest`,{headers:{'Accept-Encoding':'gzip'}});
|
||||||
|
expect(response.headers.get('etag')).toBe(`"${version.buildTag}"`);
|
||||||
|
expect(response.headers.get('content-encoding')).toBe('gzip');
|
||||||
|
expect((await response.json()).buildTag).toBe(version.buildTag);
|
||||||
|
writeFileSync(join(publicDir,'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
|
||||||
|
await stopServer(server);server=await startServer(true,false);
|
||||||
|
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = false');
|
||||||
|
await stopServer(server);server=await startServer(true,true);
|
||||||
|
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = true');
|
||||||
|
});
|
||||||
|
|
||||||
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||||
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
|||||||
Reference in New Issue
Block a user