Skip to content

Add PWA support with service worker and offline-first caching - #11

Merged
Advik-B merged 2 commits into
masterfrom
claude/pwa-auto-update-git-push-61zmz9
Aug 16, 2026
Merged

Add PWA support with service worker and offline-first caching#11
Advik-B merged 2 commits into
masterfrom
claude/pwa-auto-update-git-push-61zmz9

Conversation

@Advik-B

@Advik-B Advik-B commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Implements a complete Progressive Web App (PWA) experience for SmashZone, enabling installation as a standalone app and offline-first functionality with automatic updates.

Summary

This change adds service worker registration, precaching, and automatic update handling to make SmashZone installable and fully functional offline. The implementation includes a content-hash-based versioning system that requires no manual version bumping, automatic update detection and application, and a user-friendly update notification system.

Key Changes

  • Service Worker (src/pwa/sw.ts): Self-contained worker that handles precaching, cache versioning, and fetch interception. Implements a three-tier caching strategy:

    • Core assets (required for offline boot)
    • Heavy assets like ffmpeg (cached after install, best-effort)
    • Unhashed URLs (revalidated to prevent stale HTTP cache poisoning)
  • PWA Build Plugin (build/pwa.ts): Vite plugin that:

    • Generates cache version from SHA256 hash of all shipped files
    • Fills service worker placeholders with precache lists and version
    • Separates heavy assets (>4MB or ffmpeg) for lazy caching
    • Ensures content-hashed files are reused across builds
  • Service Worker Registration (src/pwa/register.ts): Handles:

    • Worker lifecycle management with deferred skipWaiting() to avoid interrupting matches
    • Periodic update checks (every 15 minutes, on visibility change)
    • Smart update application (automatic on menu, deferred during matches)
    • Update notification via store
  • Update Toast UI (src/ui/components/UpdateToast.svelte): Non-intrusive notification shown when an update is waiting, with options to reload immediately or wait

  • Web App Manifest & Icons:

    • public/manifest.webmanifest with proper PWA metadata
    • Generated PNG icons (192×192, 512×512, maskable variant) and SVG favicon
    • Icon generation script (build/gen-icons.ts) using Playwright
  • Build Identity (build/buildid.ts): Resolves build identifier from:

    • BUILD_ID environment variable (CI/Docker)
    • Git SHA (local builds)
    • "dev" fallback (no git available)
  • Server Updates:

    • MIME type support for .webmanifest and .ogg files
    • Cache-control headers: no-cache for sw.js (always revalidate), immutable for content-hashed assets
    • SPA fallback only for navigation requests, not for sw.js
  • E2E Tests (e2e/specs/15-pwa.spec.ts): Comprehensive PWA testing including:

    • Manifest validation and icon availability
    • Service worker delivery and cache headers
    • Cache version generation verification
    • Offline boot with network disabled

Notable Implementation Details

  • No Manual Version Bumping: Cache version is derived from content hash of the entire build output, eliminating the classic stale-PWA bug where a hand-written constant gets forgotten
  • Docker-Friendly: Works in Docker builds without .git directory by using content hash instead of git SHA for cache versioning
  • Match-Safe Updates: Updates are deferred until the player returns to the menu, preventing interruption during active gameplay
  • Bandwidth Aware: Respects Data Saver preference by skipping heavy asset precaching
  • Atomic Cache Swaps: One immutable cache per build version ensures clients never see mixed old/new chunks
  • Lazy Heavy Asset Loading: ffmpeg core (~32MB) is cached after install succeeds, so quota errors don't break the whole installation

https://claude.ai/code/session_011wwZt4TR3iwDE33Rv7iPiC

claude added 2 commits August 15, 2026 21:18
The web client had no manifest, no icons, no worker and nothing offline.
Add all four, and derive the cache version from the build so nobody ever
edits a version constant to ship an update.

How the versioning works: client/build/pwa.ts hashes every file in the
finished dist/ and stamps that hash (plus the precache lists) into
dist/sw.js. A git SHA would not do — the Docker build copies no .git, so
an unpassed BUILD_ID would silently freeze at "dev" and installed clients
would never update. A content hash changes exactly when the shipped bytes
change, in every build path, configured by nothing.

The worker precaches the whole bundle (shell, engine, wasm sim, model,
fonts, audio) and carries unchanged entries forward from the previous
version, so an update usually re-downloads only what changed. The ~32 MB
ffmpeg core is a separate best-effort tier: needed for offline export, but
a storage-quota rejection there must not fail the install and take offline
boot down with it. Data Saver skips it; the fetch handler picks it up
lazily on first use.

Updates never interrupt a match. The worker does not call skipWaiting();
src/pwa/register.ts applies a waiting build immediately when the player is
on the menu, and otherwise shows a toast and swaps on the way back. Open
tabs re-check on load, on refocus, and every 15 minutes.

Also fixes two things the PWA depends on: the server marked everything
under assets/ immutable for a year, including files copied verbatim out of
public/ (audio, fonts, robot.glb), so a replaced sound could never reach a
browser; build.rs now flags only content-hashed names. And .webmanifest
and .ogg had no mime type.

BUILD_ID (the replay-header stamp) now falls back to the working tree's
git SHA instead of "dev".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wwZt4TR3iwDE33Rv7iPiC
The manifest's display:"fullscreen" only applies to an installed launch, so
every player in a browser tab — nearly all of them — was still looking at a
windowed page. Nothing called the Fullscreen API at all.

- game/fullscreen.ts owns the lot: enter/exit/toggle with the webkit fallbacks
  Safari still needs, a persisted sz-fullscreen preference (on by default,
  following the savedQuality pattern), and a best-effort landscape lock on
  phones, which is the orientation the thumbstick and action buttons are laid
  out for.
- Starting a match goes fullscreen, requested synchronously from the click:
  browsers only grant it while that gesture is still active, so it cannot wait
  for ensureMode() or the room POST.
- A toggle button top-right of the menu, plus Settings > Display. The menu
  button is what gives touch players access at all — the settings gear is
  hidden on touch.
- pwa/install.ts captures beforeinstallprompt and the menu offers INSTALL APP.
  On iPhone that is the only route to a chrome-free game, since Safari there
  has no Fullscreen API. The listener attaches before main() because the event
  fires while the sim, model and audio are still loading.
- Manifest declares orientation: landscape.

zz-restart builds its own browser context, so it never got the ?nosw=1 opt-out
newGamePage gives every other spec: it was precaching the whole bundle from the
disposable server the test then SIGTERMs, and the shutdown notice lost the race
against a ~35 MB download, leaving the client on its generic "connection lost".

Headless Chromium grants requestFullscreen with no user gesture whatsoever, so
the gesture-ordering rule cannot be asserted from a spec; 16-fullscreen.spec.ts
says so rather than implying coverage it does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wwZt4TR3iwDE33Rv7iPiC
@Advik-B
Advik-B merged commit 2136873 into master Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants