Part 8 of 8
LeXi: the installable PWA and the custom domain
iOS support, service-worker caching on a custom domain, the SW that never registered and the bot's timezone.
The last milestone: make LeXi truly installable and serve it from the custom domain your-domain.com. It was the milestone with the most surprise bugs of the whole series, all related to the difference between “works locally” and “works in production”.
iOS support
src/layouts/Layout.astro — in the <head> we added the tags iOS needs for “Add to Home Screen”:
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="LeXi" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
Manifest screenshots
scripts/generate-screenshots.mjs (with sharp) generates two screenshots for the richer install dialog:
screenshots: [
{ src: '/screenshots/lexi-mobile.png', sizes: '410x917', form_factor: 'narrow' },
{ src: '/screenshots/lexi-desktop.png', sizes: '1919x966', form_factor: 'wide' }
]
Lesson: the manifest
sizesmust match the actual PNG dimensions. We generated placeholders and the user replaced them with real captures; we had to update the sizes inastro.config.mjsor Chrome ignores the screenshot.
The SW that never registered
Verifying with Puppeteer against production I found that the Service Worker never registered. The plugin generated registerSW.js (and precached it inside sw.js), but did not inject it into Astro’s final HTML: vite-plugin-pwa’s transformIndexHtml does not run on Astro’s SSG output. Even injectRegister: 'script' did not fix it.
What we learned the hard way: in Astro SSG, the PWA plugin generates the files but not the registration. The fix was registering it manually in the layout’s inline
<script>:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {});
});
}
After the fix: SW active, workbox-precache-v2 precache created, and offline OK — 24 cards and 24 ARASAAC images loaded with no network.
sw.js cached on the custom domain
/sw.js responded with Cache-Control: public, max-age=14400 on the custom domain, even though pages.dev was fine. _headers was being applied (the CSP arrived), but the zone’s edge cache rewrote the header for .js.
I could not create a Cache Rule via API (the wrangler token only had zone:read). The solution was a header Cloudflare Pages does respect for edge cache:
/sw.js
Cache-Control: no-cache
CDN-Cache-Control: no-cache
Lesson:
CDN-Cache-Controlcontrols Cloudflare’s edge cache. The remainingCache-Control(max-age=14400) is browser-level, and per spec the SW update-check ignores the HTTP cache (updateViaCachedefaults to'imports'). Functionally solved.
Timezone: the bot and the stats
The Telegram bot showed times in UTC because toLocaleString('es-ES') on Workers does not inherit the local zone. We fixed it with a configurable variable:
wrangler.json:"TIME_ZONE": "Europe/Madrid".webhook.ts: aformatTime(ts, timeZone)helper.- And
/api/statsgrouped days in UTC withstrftime; we rewrote it to group in JS with the timezone (formaten-CA→YYYY-MM-DD).
Lesson: there is no “user’s local time” in Cloudflare Workers. You must force
timeZoneexplicitly. And be careful grouping days withstrftime: an event at 23:00 UTC belongs to the next day in Madrid.
Demo button
The LEXI-DEMO-CODE code already existed; we added a “Try the app (demo)” button in the welcome dialog that redeems it with one click, so anyone can explore the app without registering.
Final verification
- Lighthouse 13 removed the PWA category from core, so I verified the criteria manually (HTTPS, manifest, SW, icons 200,
start_url). - Deploy to
your-domain.comwith a DNS CNAME →your-project.pages.devand automatic SSL.
End of the series
Commits: feat: installable PWA polish, fix: prevent edge caching of sw.js, fix: register service worker in Layout, fix: create users row on device claim, fix: format bot and stats times in owner timezone, docs: mark Hito 8 complete.
With this, all 8 LeXi milestones are complete: architecture, skeleton, D1/R2, ARASAAC seed, grid+keyboard+TTS, editor, sync+stats and PWA+domain. The only roadmap item left is initial English support (i18n), whenever we get to it. End of the series!