← Public packages
@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
src/app.ts
335 lines · 12.9 KB · TypeScriptimport { 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#14161c"/><g transform="translate(14 22)"><rect width="68" height="52" rx="6" fill="#f7f6f2"/><path d="M17 0v52M34 0v52M51 0v52" stroke="#c9c6bd" stroke-width="2"/><rect x="11" width="12" height="30" rx="2" fill="#1b1c20"/><rect x="28" width="12" height="30" rx="2" fill="#f2b544"/><rect x="45" width="12" height="30" rx="2" fill="#1b1c20"/></g></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(/</g, '<')
}
function documentHtml(input: {
appBase: string
siteUrl: string
page: 'home' | 'about'
path: string
title: string
runningSha: string
viewport: string
css: string
body: string
scripts: string[]
}) {
const base = escapeAttr(input.appBase)
const site = escapeAttr(input.siteUrl)
return `<!doctype html>
<html lang="en" data-app-base="${base}" data-running-sha="${escapeAttr(input.runningSha)}" data-page="${input.page}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="${input.viewport}" />
<meta name="theme-color" content="#1d2029" />
<meta name="mobile-web-app-capable" content="yes" />
<title>${input.title}</title>
<meta name="description" content="${escapeAttr(DESCRIPTION)}" />
<link rel="canonical" href="${site}${input.path}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Pocket Piano" />
<meta property="og:title" content="${escapeAttr(input.title)}" />
<meta property="og:description" content="${escapeAttr(DESCRIPTION)}" />
<meta property="og:url" content="${site}${input.path}" />
<meta property="og:image" content="${site}/og.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Pocket Piano: a phone under a stage spotlight showing the piano app with a C major chord held, beside the title and the tagline A piano in your pocket for choir practice" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeAttr(input.title)}" />
<meta name="twitter:description" content="${escapeAttr(DESCRIPTION)}" />
<meta name="twitter:image" content="${site}/og.jpg" />
<link rel="manifest" href="${base}/manifest.webmanifest" crossorigin="use-credentials" />
<link rel="icon" href="${base}/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="${base}/icons/icon-192.png" />
<style>${input.css}${UPDATE_TOAST_CSS}</style>
</head>
<body>
${input.body}
${input.scripts.map((js) => `<script>${js}</script>`).join('\n')}
</body>
</html>`
}
const INFO_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 11v6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><circle cx="12" cy="7.5" r="1.4" fill="currentColor"/></svg>`
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: `<header>
<button id="range" type="button" aria-label="Visible range. Tap to go back to your part">C3 to C5</button>
<button id="readout" type="button" aria-label="Replay last note"><span id="readout-note">♪</span><span id="readout-hz"></span></button>
<button id="sustain" type="button" aria-pressed="false">Sustain</button>
<span class="spacer"></span>
<button id="install" type="button" hidden>Install</button>
<a id="about-link" class="info" href="${base}/about" aria-label="About Pocket Piano" title="About">${INFO_ICON}</a>
</header>
<main id="keyboard" aria-label="Piano keyboard"></main>`,
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' } })
},
}