← Public packages
@kentcdodds/asteroids
Classic canvas Asteroids game with vector graphics, touch controls, and a saved high score.
app/pwa.ts
158 lines · 7.8 KB · TypeScriptimport { BUILD } from './build-info.ts'
import { COLORS, type PageCtx } from './ui.ts'
export function manifest(ctx: PageCtx) {
const a = ctx.assetBase
return {
id: `${ctx.appBase}/`,
name: 'Asteroids',
short_name: 'Asteroids',
description: 'Classic vector Asteroids with synthesized chiptune sound. Keyboard or touch, works offline.',
lang: 'en',
start_url: `${ctx.appBase}/`,
scope: `${ctx.appBase}/`,
display: 'fullscreen',
display_override: ['fullscreen', 'standalone', 'minimal-ui'],
orientation: 'any',
background_color: COLORS.bg,
theme_color: COLORS.bg,
categories: ['games', 'entertainment'],
icons: [
{ src: `${a}/icons/icon-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: `${a}/icons/icon-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: `${a}/icons/icon-maskable-512.png`, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
}
}
/**
* Service worker, versioned by BUILD.buildId (a hash of the app + assets).
* Pattern from @kentcdodds/package-app-kit/sw: versioned cache, old caches deleted on
* activate, manifest network-first, never cache errors/opaque responses. Differences:
* pages are served cache-first from the build's own cache so a running version stays
* consistent, and a new worker waits until the player taps UPDATE (SKIP_WAITING).
*/
export function serviceWorker(ctx: PageCtx) {
const precache = [
`${ctx.appBase}/`,
`${ctx.appBase}/about`,
`${ctx.appBase}/manifest.webmanifest`,
...['og.jpg', 'icons/favicon.svg', 'icons/favicon-32.png', 'icons/apple-touch-icon.png', 'icons/icon-192.png', 'icons/icon-512.png', 'icons/icon-maskable-512.png'].map((p) => `${ctx.assetBase}/${p}`),
]
return `"use strict";
var VERSION = ${JSON.stringify(BUILD.buildId)};
var CACHE = "asteroids-" + VERSION;
var BASE = ${JSON.stringify(ctx.appBase)};
var PRECACHE = ${JSON.stringify(precache)};
function ok(r) { return Boolean(r && r.ok && r.type !== "opaque" && r.type !== "error"); }
function rel(url) { var p = url.pathname; if (BASE && p.indexOf(BASE) === 0) p = p.slice(BASE.length); return p.replace(/\\/+$/, "") || "/"; }
self.addEventListener("install", function (event) {
event.waitUntil(caches.open(CACHE).then(function (cache) {
return Promise.all(PRECACHE.map(function (u) {
return fetch(new Request(u, { cache: "reload", credentials: "same-origin" })).then(function (r) { if (ok(r)) return cache.put(u, r); });
}));
}));
// First install has nothing to replace, so take over right away. Later versions wait for UPDATE.
if (!self.registration.active) self.skipWaiting();
});
self.addEventListener("message", function (event) {
if (event.data === "SKIP_WAITING") self.skipWaiting();
if (event.data === "VERSION" && event.ports[0]) event.ports[0].postMessage(VERSION);
});
self.addEventListener("activate", function (event) {
event.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k.indexOf("asteroids-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener("fetch", function (event) {
var req = event.request;
if (req.method !== "GET") return;
var url = new URL(req.url);
if (url.origin !== self.location.origin) return;
var path = rel(url);
if (path === "/version.json" || path === "/health" || path === "/sw.js") return;
if (path === "/manifest.webmanifest") {
event.respondWith(fetch(req, { credentials: "same-origin", cache: "no-store" }).then(function (r) {
return ok(r) ? r : caches.match(req).then(function (c) { return c || r; });
}).catch(function () { return caches.match(req).then(function (c) { return c || Response.error(); }); }));
return;
}
if (req.mode === "navigate" || path === "/" || path === "/about") {
var key = BASE + (path === "/about" ? "/about" : "/");
event.respondWith(caches.open(CACHE).then(function (cache) {
return cache.match(key).then(function (cached) {
if (cached) return cached;
return fetch(req).then(function (r) { if (ok(r)) cache.put(key, r.clone()); return r; });
});
}));
return;
}
event.respondWith(caches.open(CACHE).then(function (cache) {
return cache.match(req).then(function (cached) {
return cached || fetch(req).then(function (r) { if (ok(r)) cache.put(req, r.clone()); return r; });
});
}));
});
`
}
/**
* Browser-side PWA glue shared by the game and About pages: registers the worker,
* shows the arcade UPDATE AVAILABLE toast when a new version is waiting, wires the
* INSTALL button to the native prompt, and exposes window.__pwa for the About page/tests.
*/
export const pwaScript = String.raw`
(() => {
const root = document.documentElement, base = root.dataset.appBase || '', build = root.dataset.build || '';
const toast = document.getElementById('toast'), installBtn = document.getElementById('installBtn');
const state = { build, registered: false, waiting: false, installable: false, lastCheck: 0 };
let reg = null, deferred = null, reloading = false;
const listeners = new Set();
const emit = () => listeners.forEach((f) => { try { f(state) } catch {} });
function showUpdate(worker) {
state.waiting = true; emit();
if (!toast) return;
toast.hidden = false;
const btn = toast.querySelector('[data-update]');
btn.onclick = () => { btn.textContent = 'UPDATING…'; worker.postMessage('SKIP_WAITING'); };
const later = toast.querySelector('[data-later]');
if (later) later.onclick = () => { toast.hidden = true; };
}
function track(r) {
if (r.waiting && navigator.serviceWorker.controller) showUpdate(r.waiting);
r.addEventListener('updatefound', () => {
const w = r.installing; if (!w) return;
w.addEventListener('statechange', () => { if (w.state === 'installed' && navigator.serviceWorker.controller) showUpdate(w); });
});
}
async function check() {
state.lastCheck = Date.now();
if (!reg) return { ok: false, reason: 'unsupported' };
try { await reg.update(); } catch (e) { return { ok: false, reason: 'offline' }; }
// Give a found update a moment to install so callers can report it.
for (let i = 0; i < 40 && reg.installing; i++) await new Promise((r) => setTimeout(r, 100));
return { ok: true, waiting: Boolean(reg.waiting) };
}
if ('serviceWorker' in navigator && location.protocol !== 'file:') {
navigator.serviceWorker.addEventListener('controllerchange', () => { if (state.waiting && !reloading) { reloading = true; location.reload(); } });
navigator.serviceWorker.register(base + '/sw.js', { scope: base + '/' }).then((r) => {
reg = r; state.registered = true; emit(); track(r);
setInterval(() => { if (document.visibilityState === 'visible') check(); }, 30 * 60 * 1000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - state.lastCheck > 60000) check(); });
}).catch(() => {});
}
const standalone = matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true;
addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); if (standalone) return;
deferred = e; state.installable = true; emit();
if (installBtn) installBtn.hidden = false;
});
addEventListener('appinstalled', () => { deferred = null; state.installable = false; emit(); if (installBtn) installBtn.hidden = true; });
if (installBtn) installBtn.addEventListener('click', async (e) => {
e.preventDefault(); installBtn.blur(); if (!deferred) return;
deferred.prompt(); try { await deferred.userChoice; } catch {}
deferred = null; state.installable = false; installBtn.hidden = true; emit();
});
window.__pwa = { state, check, onChange: (f) => { listeners.add(f); f(state); } };
})();
`