import { packageContext } from 'kody:runtime' import { ABOUT_CSS, ABOUT_JS, UPDATE_TOAST_CSS, aboutBodyHtml } from './about-page.ts' import { CLIENT_JS } from './client-script.ts' import { COLORING_CSS, COLORING_JS, coloringBodyHtml } from './coloring-page.ts' import { allImagePaths, imageSourceFor, serveImage } from './images.ts' import { PAINTINGS } from './paintings.ts' import { readRecordedVersion } from './record-version.ts' import { OG_IMAGE_JPEG } from './og-image.ts' import { CSS } from './styles.ts' import { UPDATE_JS } from './update-script.ts' import type { AppVersion } from './version.ts' // Bump to rotate the service worker's shell cache. Images live in a separate cache keyed // by path, so they survive shell updates. const VERSION = '4' const DESCRIPTION = 'Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church.' const ICON_FILES = ['icon-192.png', 'icon-512.png', 'maskable-192.png', 'maskable-512.png'] const ICON_SVG = `` const COLOR_ICON = `` const INFO_ICON = `` const HOST = /^[a-z0-9.-]+(:\d{1,5})?$/i /** * Public origin of this request, for the absolute canonical, og:url, and og:image URLs. * Comes from the forwarded host (custom domain or kody.run), so forks need no changes. * Every in-app URL is relative to packageContext.appBasePath instead. */ function publicOrigin(request: Request, url: URL) { const raw = (request.headers.get('x-forwarded-host') || request.headers.get('host') || url.host).split(',')[0]!.trim() if (!HOST.test(raw)) return url.origin const local = /^(localhost|127\.|\[::1\])/i.test(raw) return `${local ? 'http' : 'https'}://${raw.toLowerCase()}` } const OG_ALT = 'Paintings of Jesus Christ: a phone showing the painting Jesus the Christ by Del Parson in the app, beside the title and the words Official Latter-day Saint art for children at church' function escapeAttr(value: string) { return value.replace(/&/g, '&').replace(/"/g, '"').replace(/ ${input.title} ${input.body} ${input.scripts.map((js) => ``).join('\n')} ` } function viewerPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) { const data = JSON.stringify( PAINTINGS.map((p) => ({ id: p.id, title: p.title, artist: p.artist, alt: p.alt, width: p.width, height: p.height })), ).replace(/` return documentHtml({ appBase, assetBase, page: 'home', siteUrl, path: '/', title: 'Paintings of Jesus Christ', runningSha: version.sha, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover', css: CSS, body: `
${slide} ${slide} ${slide}
${COLOR_ICON}${INFO_ICON}
`, scripts: [UPDATE_JS, CLIENT_JS], }) } function aboutPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) { return documentHtml({ appBase, assetBase, page: 'about', siteUrl, path: '/about', title: 'About these paintings', runningSha: version.sha, viewport: 'width=device-width, initial-scale=1, viewport-fit=cover', css: ABOUT_CSS, body: aboutBodyHtml(escapeAttr(appBase), escapeAttr(assetBase), version), scripts: [UPDATE_JS, ABOUT_JS], }) } function coloringPage(appBase: string, assetBase: string, siteUrl: string, version: AppVersion) { return documentHtml({ appBase, assetBase, page: 'coloring', siteUrl, path: '/coloring', title: 'Coloring pages of Jesus Christ', runningSha: version.sha, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover', css: COLORING_CSS, body: coloringBodyHtml(escapeAttr(appBase)), scripts: [UPDATE_JS, COLORING_JS], }) } function manifest(appBase: string, assetBase: string) { const icon = (file: string, size: string, purpose: string) => ({ src: `${assetBase}/icons/${file}`, sizes: size, type: 'image/png', purpose, }) return JSON.stringify({ id: `${appBase}/`, name: 'Paintings of Jesus Christ', short_name: 'Paintings', description: DESCRIPTION, start_url: `${appBase}/`, scope: `${appBase}/`, display: 'fullscreen', display_override: ['fullscreen', 'standalone'], orientation: 'any', background_color: '#0b0b0e', theme_color: '#0b0b0e', categories: ['education', 'lifestyle'], icons: [ icon('icon-192.png', '192x192', 'any'), icon('icon-512.png', '512x512', 'any'), icon('maskable-192.png', '192x192', 'maskable'), icon('maskable-512.png', '512x512', 'maskable'), ], }) } function serviceWorker(appBase: string, assetBase: string) { // Follows the package-app-kit starter worker (starter-remix/public/sw.js): HTML pages are // cache first with a background refresh, the manifest and icons are network first, // /api/* is never cached, Kody gate pages (403, redirects, or x-kody-handoff) are never // cached, and SKIP_WAITING / REFRESH_SHELL messages power the update toast's Refresh // button. Images (paintings and coloring pages) are cache first in their own cache. // After activation, and whenever a page sends WARM_ALL, the worker downloads every // image it does not have yet, one at a time, and reports progress to open pages with // WARM_PROGRESS messages, so the whole app works offline after the first visit. const pages = ['/', '/about', '/coloring'] const shell = [ ...pages.map((page) => `${appBase}${page}`), `${appBase}/manifest.webmanifest`, `${appBase}/icon.svg`, ...ICON_FILES.map((file) => `${assetBase}/icons/${file}`), ] return `'use strict' const CACHE = 'christ-paintings-v${VERSION}' const IMAGES = 'christ-paintings-images' const BASE = ${JSON.stringify(appBase)} const IMAGE_PATH = ${JSON.stringify(`${appBase}/img/`)} const ALL_IMAGES = ${JSON.stringify(allImagePaths().map((path) => `${appBase}${path}`))} const PAGES = ${JSON.stringify(pages)} const SHELL = ${JSON.stringify(shell)} const isHandoff = (res) => res && (res.status === 403 || String(res.headers.get('x-kody-handoff') || '').toLowerCase() === 'required') const isImage = (url) => /\\.(png|svg|ico|webp|jpg|jpeg)$/.test(url.pathname) const good = (url, res) => { if (!res || !res.ok || res.redirected || res.type !== 'basic' || isHandoff(res)) return false if (isImage(url)) return String(res.headers.get('content-type') || '').startsWith('image/') return true } const rel = (url) => { const path = url.pathname if (path === BASE || path === BASE + '/') return '/' return path.startsWith(BASE + '/') ? path.slice(BASE.length).replace(/\\/$/, '') || '/' : path } const pageKey = (url) => BASE + rel(url) const isPage = (url) => PAGES.includes(rel(url)) const isPicture = (url) => url.pathname.startsWith(IMAGE_PATH) function put(cache, key, url, res) { if (good(url, res)) return cache.put(key, res.clone()) } function fetchInto(cache, path) { const url = new URL(path, location.origin) return fetch(path, { credentials: 'same-origin', cache: 'no-store' }) .then((res) => put(cache, isPage(url) ? pageKey(url) : path, url, res)) .catch(() => null) } async function broadcast(message) { const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }) for (const client of list) client.postMessage(message) } async function countCached(cache) { let done = 0 for (const path of ALL_IMAGES) if (await cache.match(path)) done++ return done } let warming = null function warm() { if (warming) return warming const wanted = new Set(ALL_IMAGES) warming = caches.open(IMAGES).then(async (cache) => { const keys = await cache.keys() await Promise.all(keys.filter((req) => !wanted.has(new URL(req.url).pathname)).map((req) => cache.delete(req))) let done = await countCached(cache) let failed = false await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: true }) for (const path of ALL_IMAGES) { if (await cache.match(path)) continue try { const res = await fetch(path, { credentials: 'same-origin' }) if (good(new URL(path, location.origin), res)) { await cache.put(path, res) done++ await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: true }) } else if (isHandoff(res)) { failed = true break } } catch (e) { failed = true break } } await broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: false, failed }) }).finally(() => { warming = null }) return warming } self.addEventListener('install', (e) => { e.waitUntil( caches.open(CACHE) .then((cache) => Promise.all(SHELL.map((path) => fetchInto(cache, path)))) .then(() => self.skipWaiting()), ) }) self.addEventListener('activate', (e) => { e.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((k) => k.startsWith('christ-paintings-v') && k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()) .then(() => { warm() }), ) }) self.addEventListener('message', (e) => { if (e.data === 'SKIP_WAITING') return self.skipWaiting() if (e.data && (e.data.type === 'WARM_ALL' || e.data.type === 'WARM_PAINTINGS')) { e.waitUntil(warm()) return } if (e.data && e.data.type === 'WARM_STATUS') { e.waitUntil(caches.open(IMAGES).then(countCached).then((done) => broadcast({ type: 'WARM_PROGRESS', done, total: ALL_IMAGES.length, running: Boolean(warming) }))) return } if (e.data && e.data.type === 'REFRESH_SHELL') { const reply = () => e.ports && e.ports[0] && e.ports[0].postMessage({ type: 'REFRESHED' }) e.waitUntil( caches.open(CACHE) .then((cache) => Promise.all(PAGES.map((page) => fetchInto(cache, BASE + page)))) .then(reply, reply), ) } }) self.addEventListener('fetch', (e) => { const req = e.request if (req.method !== 'GET') return const url = new URL(req.url) if (url.origin !== location.origin || !url.pathname.startsWith(BASE)) return if (url.pathname.startsWith(BASE + '/api/')) return // Let Kody's session handoff (query string) reach the network untouched. if (req.mode === 'navigate' && url.search) return if (isPicture(url)) { e.respondWith( caches.open(IMAGES).then((cache) => cache.match(url.pathname).then((hit) => { if (hit) return hit return fetch(req) .then((res) => { if (good(url, res)) cache.put(url.pathname, res.clone()) return res }) .catch(() => Response.error()) }), ), ) return } if (req.mode === 'navigate' || isPage(url)) { const key = pageKey(url) e.respondWith( caches.open(CACHE).then((cache) => cache.match(key).then((hit) => { const network = fetch(req) .then((res) => { put(cache, key, url, res) return res }) .catch(() => null) if (hit) { e.waitUntil(network) return hit } return network.then((res) => res || Response.error()) }), ), ) return } // Manifest, icons, everything else: network first, cached copy when offline or gated. e.respondWith( caches.open(CACHE).then((cache) => fetch(req, { credentials: 'same-origin', cache: url.pathname.endsWith('.webmanifest') ? 'no-store' : 'default' }) .then((res) => { if (good(url, res)) { cache.put(url.pathname, res.clone()) return res } return cache.match(url.pathname).then((hit) => hit || res) }) .catch(() => cache.match(url.pathname).then((hit) => hit || Response.error())), ), ) }) ` } /** * Paintings of Jesus Christ: a phone-first viewer for official artwork from The Church of * Jesus Christ of Latter-day Saints, plus a coloring section. Serves the viewer, /coloring, * the About page, /api/version, the web app manifest, an SVG icon, an offline service * worker, and the Church's image files at /img/p/{id}.jpg and /img/c/{id}.jpg (see * images.ts). PNG icons are static assets under packageContext.assetBasePath. */ export default { async fetch(request: Request) { const url = new URL(request.url) const { appBase, assetBase } = paths() const path = url.pathname.replace(/\/+$/, '') || '/' if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } }) } if (path === '/') { return new Response(viewerPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, }) } if (path === '/about') { return new Response(aboutPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, }) } if (path === '/coloring') { return new Response(coloringPage(appBase, assetBase, publicOrigin(request, url) + appBase, await readRecordedVersion()), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, }) } if (path.startsWith('/img/')) { const source = imageSourceFor(path) if (!source) return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }) return serveImage(request, source) } if (path === '/api/version') { return Response.json(await readRecordedVersion(), { headers: { 'cache-control': 'no-store' } }) } if (path === '/manifest.webmanifest') { return new Response(manifest(appBase, assetBase), { headers: { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' }, }) } if (path === '/og.jpg') { const bytes = Uint8Array.from(atob(OG_IMAGE_JPEG), (c) => c.charCodeAt(0)) return new Response(request.method === 'HEAD' ? null : bytes, { headers: { 'content-type': 'image/jpeg', 'cache-control': 'public, max-age=86400' }, }) } if (path === '/icon.svg') { return new Response(ICON_SVG, { headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' }, }) } if (path === '/sw.js') { return new Response(serviceWorker(appBase, assetBase), { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'service-worker-allowed': `${appBase}/`, }, }) } return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }) }, }