← Public packages
@kentcdodds/christ-paintings
Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church, plus coloring pages of the Savior. Swipe to move. Works offline.
src/images.ts
83 lines · 3.2 KB · TypeScriptimport { COLORING_PAGES } from './coloring.ts'
import { PAINTINGS } from './paintings.ts'
/**
* Images are not stored in this repo. The app serves each one at a same-origin path
* ({appBase}/img/p/{id}.jpg for paintings, {appBase}/img/c/{id}.jpg for coloring pages)
* by fetching the Church's own file from churchofjesuschrist.org. Same origin matters:
* the service worker can cache every image normally for offline use, and the coloring
* canvas can read the line art without tainting. Only ids in the curated lists are
* served (the path must match /img/(p|c)/<slug>.jpg and the slug must be in the list),
* and the upstream URL always comes from that list, so this is not an open proxy.
*/
const SOURCES = new Map<string, string>([
...PAINTINGS.map((p) => [`p/${p.id}`, p.imageUrl] as const),
...COLORING_PAGES.map((c) => [`c/${c.id}`, c.imageUrl] as const),
])
export function imagePath(kind: 'p' | 'c', id: string) {
return `/img/${kind}/${id}.jpg`
}
export function allImagePaths() {
return [
...PAINTINGS.map((p) => imagePath('p', p.id)),
...COLORING_PAGES.map((c) => imagePath('c', c.id)),
]
}
export function totalImageBytes() {
return [...PAINTINGS, ...COLORING_PAGES].reduce((sum, item) => sum + item.bytes, 0)
}
export function imageSourceFor(path: string) {
const match = /^\/img\/([pc]\/[a-z0-9-]+)\.jpg$/.exec(path)
return match ? (SOURCES.get(match[1]) ?? null) : null
}
// The path names a fixed Church file, so browsers and shared caches may keep it for a
// year. Together with the edge cache below, each image is fetched from
// churchofjesuschrist.org rarely, not once per visitor.
const CACHE_CONTROL = 'public, max-age=31536000, immutable'
function headers(cache: 'hit' | 'miss') {
return {
'content-type': 'image/jpeg',
'cache-control': CACHE_CONTROL,
'x-content-type-options': 'nosniff',
'x-image-cache': cache,
}
}
export async function serveImage(request: Request, source: string) {
const edge = (globalThis as { caches?: { default?: Cache } }).caches?.default
const key = new Request(source)
if (edge) {
const hit = await edge.match(key).catch(() => undefined)
if (hit) return withHeaders(hit, request, 'hit')
}
let upstream: Response
try {
// source always comes from the curated map, never from the request.
upstream = await fetch(source, { headers: { accept: 'image/jpeg,image/*' }, redirect: 'manual' })
} catch {
return unavailable()
}
const type = upstream.headers.get('content-type') ?? ''
if (!upstream.ok || !type.startsWith('image/')) return unavailable()
const body = await upstream.arrayBuffer()
if (edge) {
const copy = new Response(body.slice(0), { headers: { 'content-type': 'image/jpeg', 'cache-control': CACHE_CONTROL } })
await edge.put(key, copy).catch(() => undefined)
}
return withHeaders(new Response(body), request, 'miss')
}
function unavailable() {
return new Response('Image unavailable', { status: 502, headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' } })
}
async function withHeaders(res: Response, request: Request, cache: 'hit' | 'miss') {
const body = request.method === 'HEAD' ? null : await res.arrayBuffer()
return new Response(body, { status: 200, headers: headers(cache) })
}