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 { PNG_ICONS } from './icons.ts' import { OG_IMAGE_JPEG } from './og-image.ts' import { PARTS_JS } from './parts.ts' import { readRecordedVersion } from './record-version.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 cache. const VERSION = '9' const ICON_SVG = `` 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 DESCRIPTION = 'A practice piano for choir on your phone. Pick your singing part, play notes and chords, install it, and use it offline.' const ICON_FILES = ['icon-192.png', 'icon-512.png', 'maskable-192.png', 'maskable-512.png'] function escapeAttr(value: string) { return value.replace(/&/g, '&').replace(/"/g, '"').replace(/ ${input.title} ${input.body} ${input.scripts.map((js) => ``).join('\n')} ` } const INFO_ICON = `` function pianoPage(appBase: string, siteUrl: string, version: AppVersion) { const base = escapeAttr(appBase) return documentHtml({ appBase, siteUrl, page: 'home', path: '/', title: 'Pocket Piano', runningSha: version.sha, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover', css: CSS, body: `
${INFO_ICON}
`, scripts: [UPDATE_JS, PARTS_JS, CLIENT_JS], }) } function aboutPage(appBase: string, siteUrl: string, version: AppVersion) { return documentHtml({ appBase, siteUrl, page: 'about', path: '/about', title: 'About Pocket Piano', runningSha: version.sha, viewport: 'width=device-width, initial-scale=1, viewport-fit=cover', css: ABOUT_CSS, body: aboutBodyHtml(escapeAttr(appBase), version), scripts: [UPDATE_JS, PARTS_JS, ABOUT_JS], }) } function manifest(appBase: string) { const icon = (file: string, size: string, purpose: string) => ({ src: `${appBase}/icons/${file}`, sizes: size, type: 'image/png', purpose, }) return JSON.stringify({ id: `${appBase}/`, name: 'Pocket Piano', short_name: 'Piano', description: DESCRIPTION, start_url: `${appBase}/`, scope: `${appBase}/`, display: 'standalone', orientation: 'any', background_color: '#14161c', theme_color: '#1d2029', categories: ['music'], 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) { // Follows the package-app-kit starter worker (starter-remix/public/sw.js): HTML pages are // cache first with a background refresh, the manifest and images are network first, // /api/* is never cached, Kody gate pages (403 or x-kody-handoff) are never cached, and // SKIP_WAITING / REFRESH_SHELL messages power the update toast's Refresh button. const pages = ['/', '/about'] const shell = [ ...pages.map((page) => `${appBase}${page}`), `${appBase}/manifest.webmanifest`, `${appBase}/icon.svg`, ...ICON_FILES.map((file) => `${appBase}/icons/${file}`), ] return `'use strict' const CACHE = 'pocket-piano-v${VERSION}' const BASE = ${JSON.stringify(appBase)} 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)) 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) } 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('pocket-piano-') && k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()), ) }) self.addEventListener('message', (e) => { if (e.data === 'SKIP_WAITING') return self.skipWaiting() 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 (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())), ), ) }) ` } function base64ToBytes(b64: string) { const bin = atob(b64) const bytes = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) return bytes } /** * Pocket Piano app: a mobile-first practice keyboard that opens on your singing part's range. * Serves the piano, the About page, /api/version, the web app manifest, PNG and SVG icons, * and an offline service worker. */ export default { async fetch(request: Request) { const url = new URL(request.url) const appBase = (packageContext?.appBasePath ?? '').replace(/\/+$/, '') 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(pianoPage(appBase, 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, publicOrigin(request, url) + appBase, await readRecordedVersion()), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, }) } if (path === '/api/version') { return Response.json(await readRecordedVersion(), { headers: { 'cache-control': 'no-store' } }) } if (path === '/manifest.webmanifest') { return new Response(manifest(appBase), { headers: { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' }, }) } if (path === '/og.jpg') { return new Response(base64ToBytes(OG_IMAGE_JPEG), { 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' }, }) } const iconMatch = path.match(/^\/icons\/([a-z0-9-]+\.png)$/) if (iconMatch && PNG_ICONS[iconMatch[1]]) { return new Response(base64ToBytes(PNG_ICONS[iconMatch[1]]), { headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' }, }) } if (path === '/sw.js') { return new Response(serviceWorker(appBase), { 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' } }) }, }