- commit
- d0461c2d4dcb2798d65f6f9cf553ae156650924a
- parent
- 22d0b46654c9ca6c0cd0a818189410b6546e0ec0
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | _headers | 17 | +++++++- |
| M | scripts/ | 70 | ++++++++++++++++++++++++------ |
| M | sw.js | 20 | ++++++--- |
@@ -39,7 +39,7 @@ npx wrangler@4 deploy --dry-run
3939 npx wrangler@4 deploy4040 ```4141 42-The config serves only `dist/`; it has no backend, secrets, storage bindings, analytics, photo upload endpoint, or runtime npm dependencies. Re-run packaging after changes: the service-worker version derives from the bundled files. Existing open tabs finish using their cached version; close all app tabs and reopen to activate an update.42+The config serves only `dist/`; it has no backend, secrets, storage bindings, analytics, photo upload endpoint, or runtime npm dependencies. Packaging writes scripts, styles and wasm to `dist/static/` under content-hashed names and rewrites references, so hashed files are cached forever while pages are fetched network-first. Re-run packaging after changes. Existing open tabs finish using their cached version; close all app tabs and reopen to activate an update.4343 4444 ## Layout4545 @@ -3,7 +3,10 @@
33 Referrer-Policy: no-referrer44 Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'55 6-/assets/core.wasm6+/static/*7+ Cache-Control: public, max-age=31536000, immutable8+9+/*.wasm710 Content-Type: application/wasm811 912 /*.mjs@@ -15,5 +18,17 @@
1518 /sw.js1619 Cache-Control: no-cache1720 21+/*.html22+ Cache-Control: no-cache23+24+/25+ Cache-Control: no-cache26+27+/perspective28+ Cache-Control: no-cache29+30+/fuji31+ Cache-Control: no-cache32+1833 /manifest.webmanifest1934 Content-Type: application/manifest+json@@ -1,17 +1,61 @@
1-"""Package public assets into dist/.1+"""Package public assets into dist/ with content-hashed filenames.22 SPDX-License-Identifier: AGPL-3.0-or-later3+4+Scripts, styles and wasm land in dist/static/ as name.<hash>.ext and every5+reference to them is rewritten, so browsers and the service worker can cache6+them forever while HTML pages are always fetched fresh.37 """48 from pathlib import Path5-import shutil, hashlib6-root=Path(__file__).resolve().parent.parent7-dist=root/'dist'8-dist.mkdir(exist_ok=True)9-public=['index.html','perspective.html','fuji.html','style.css','viewport.js','home.mjs','app.mjs','fuji.mjs','raf.mjs','worker.mjs','geometry.mjs','manifest.webmanifest','NOTICE.md','README.md','_headers']10-for f in public: shutil.copyfile(root/f,dist/f)11-for d in ['assets','LICENSES']: shutil.copytree(root/d,dist/d,dirs_exist_ok=True)12-digest=hashlib.sha256()9+import shutil, hashlib, re10+root = Path(__file__).resolve().parent.parent11+dist = root / 'dist'12+if dist.exists(): shutil.rmtree(dist)13+(dist / 'static').mkdir(parents=True)14+15+# Hashed files in dependency order: leaves first, so a file's hash covers its rewritten references.16+HASHED = ['assets/core.wasm', 'assets/core.mjs', 'geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'fuji.mjs', 'home.mjs', 'viewport.js', 'style.css']17+# Fixed-name text files that reference hashed ones.18+PAGES = ['index.html', 'perspective.html', 'fuji.html']19+FIXED = ['manifest.webmanifest', 'NOTICE.md', 'README.md', '_headers']20+DIRS = ['LICENSES']21+ICONS = ['assets/icon.svg', 'assets/icon-192.png', 'assets/icon-512.png']22+23+def digest(data): return hashlib.sha256(data).hexdigest()[:10]24+mapping = {} # source path -> dist path (relative to dist)25+26+def rewrite(text, at_dir):27+ """Replace './<source path>' references with the hashed dist path relative to at_dir."""28+ for src, dst in mapping.items():29+ rel = Path(dst).relative_to(at_dir) if Path(dst).is_relative_to(at_dir) else Path('..') / dst30+ text = text.replace("'./" + src + "'", "'./" + rel.as_posix() + "'").replace('"./' + src + '"', '"./' + rel.as_posix() + '"')31+ return text32+33+for src in HASHED:34+ p = root / src35+ data = p.read_bytes()36+ if p.suffix != '.wasm':37+ text = rewrite(data.decode(), 'static')38+ if src == 'assets/core.mjs': # emscripten loader refers to its binary by bare name39+ text = text.replace('"core.wasm"', '"' + Path(mapping['assets/core.wasm']).name + '"')40+ data = text.encode()41+ name = f'{p.stem}.{digest(data)}{p.suffix}'42+ mapping[src] = f'static/{name}'43+ (dist / 'static' / name).write_bytes(data)44+45+for src in PAGES + FIXED:46+ (dist / src).parent.mkdir(parents=True, exist_ok=True)47+ text = (root / src).read_text()48+ if src.endswith('.html'): text = rewrite(text, '.')49+ (dist / src).write_text(text)50+for d in DIRS: shutil.copytree(root / d, dist / d)51+(dist / 'assets').mkdir(exist_ok=True)52+for src in ICONS: shutil.copyfile(root / src, dist / src)53+54+# Service worker: precache list and a cache name derived from everything in dist.55+precache = ['./', './perspective', './fuji'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]56+h = hashlib.sha256()1357 for p in sorted(dist.rglob('*')):14- if p.is_file() and p.name!='sw.js': digest.update(p.read_bytes())15-sw=(root/'sw.js').read_text().replace("gudpics-v1",'gudpics-'+digest.hexdigest()[:12])16-(dist/'sw.js').write_text(sw)17-print(f'Packaged {sum(1 for p in dist.rglob("*") if p.is_file())} files')58+ if p.is_file(): h.update(p.read_bytes())59+sw = (root / 'sw.js').read_text().replace('__CACHE__', 'gudpics-' + h.hexdigest()[:12]).replace('__FILES__', ','.join(f"'{f}'" for f in precache))60+(dist / 'sw.js').write_text(sw)61+print(f'Packaged {sum(1 for p in dist.rglob("*") if p.is_file())} files; ' + ', '.join(Path(v).name for v in mapping.values()))@@ -1,17 +1,23 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later2-const CACHE='gudpics-v1';3-const FILES=['./','./index.html','./perspective','./fuji','./style.css','./viewport.js','./home.mjs','./app.mjs','./fuji.mjs','./raf.mjs','./worker.mjs','./geometry.mjs','./manifest.webmanifest','./assets/core.mjs','./assets/core.wasm','./assets/icon.svg','./assets/icon-192.png','./assets/icon-512.png','./NOTICE.md','./LICENSES/AGPL-3.0.txt','./LICENSES/GPL-3.0.txt'];2+// The cache name and precache list are filled in by scripts/package.py.3+const CACHE='__CACHE__';4+const FILES=[__FILES__];5+const PAGES={'/':'./','/perspective':'./perspective','/fuji':'./fuji'};46 self.addEventListener('install',e=>{self.skipWaiting();e.waitUntil(caches.open(CACHE).then(c=>c.addAll(FILES)));});57 self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));68 self.addEventListener('fetch',e=>{79 const url=new URL(e.request.url);810 if(e.request.method!=='GET'||url.origin!==self.location.origin)return;11+ const path=url.pathname.replace(/\/+$/,'')||'/';12+ if(e.request.mode==='navigate'||path in PAGES){13+ // Pages: network first so a new deploy is picked up on the next open; cache only when offline.14+ e.respondWith(fetch(e.request).then(r=>{if(r.ok)caches.open(CACHE).then(c=>c.put(PAGES[path]||e.request,r.clone()));return r;})15+ .catch(async()=>{const c=await caches.open(CACHE);return (await c.match(PAGES[path]||'./'))||c.match('./');}));16+ return;17+ }18+ // Hashed assets never change under the same name: cache first.919 e.respondWith(caches.open(CACHE).then(async c=>{1020 const cached=await c.match(e.request,{ignoreSearch:true});if(cached)return cached;11- try{return await fetch(e.request);}12- catch(error){13- if(e.request.mode==='navigate')return (await c.match(url.pathname.replace(/\/$/,'').endsWith('/perspective')?'./perspective':'./index.html'))||c.match('./index.html');14- throw error;15- }21+ const r=await fetch(e.request);if(r.ok&&url.pathname.startsWith('/static/'))c.put(e.request,r.clone());return r;1622 }));1723 });