Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

src/app.ts

335 lines · 12.9 KB · TypeScript
Raw
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 = `<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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

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">&#9834;</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' } })
	},
}