Skip to content

app/pwa.ts

158 lines · 7.8 KB · TypeScript
Raw
import { 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); } };
})();
`