Files
scrabble-game/ui/src/lib/pwa.svelte.ts
T
Ilia Denisov 1a95a5f2cb
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
feat(offline): app-shell precache service worker (vite-plugin-pwa)
Migrate the former install-only public/sw.js to a custom (injectManifest)
service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the
app shell + hashed assets (Workbox) so an installed web PWA cold-launches with
no network, and falls in-scope navigations back to the precached shell (the
hash router resolves the route client-side). This is the C1 prerequisite for a
usable offline mode: without it a cold offline launch cannot load the bundle.

- ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches +
  precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html,
  deny-listing the RPC path and /_gm. The Connect stream and API POSTs are
  never precached nor intercepted.
- vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own),
  injectRegister:false (registration stays manual + web-only in pwa.svelte.ts),
  disabled in the mock build (Playwright unperturbed); landing + polyfills
  excluded from the precache.
- Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged.
- New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}.

Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified
(the mock e2e harness disables the SW).
2026-07-06 11:06:59 +02:00

91 lines
3.7 KiB
TypeScript

// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed
// state, and registers the install-only service worker. The pure platform branching lives in
// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts).
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
* pwa.ts, so that module stays free of app imports for the node unit tests. */
function inMiniApp(): boolean {
return insideTelegram() || insideVK();
}
// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types.
interface BeforeInstallPromptEvent extends Event {
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
appinstalled: Event;
}
}
// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription).
const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({
deferred: null,
installed: isStandalone(),
});
/**
* installMode is the reactive install-CTA mode for the current platform and any captured prompt.
* Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives
* (beforeinstallprompt fires shortly after load) or the app is installed.
*/
export function installMode(): InstallMode {
return resolveInstallMode({
deferredAvailable: install.deferred !== null,
standalone: install.installed,
iosSafari: isIosSafari(),
inMiniApp: inMiniApp(),
});
}
/**
* initInstallListeners wires the Chromium install prompt and the installed event. Harmless on
* any platform — the events fire only in an installable browser — so it may run unconditionally
* at startup, early enough to catch a prompt that fires before the CTA mounts.
*/
export function initInstallListeners(): void {
if (typeof window === 'undefined') return;
window.addEventListener('beforeinstallprompt', (e) => {
// Suppress Chromium's mini-infobar; the app presents its own CTA instead.
e.preventDefault();
install.deferred = e;
});
window.addEventListener('appinstalled', () => {
install.deferred = null;
install.installed = true;
});
}
/**
* promptInstall shows the native Chromium install dialog. It MUST be called directly from a user
* gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is
* single-use, so it is cleared here; a no-op when nothing was captured.
*/
export async function promptInstall(): Promise<void> {
const deferred = install.deferred;
if (!deferred) return;
install.deferred = null;
await deferred.prompt();
}
/**
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
* the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build
* (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort —
* a failed registration only means the app is not installable and cannot launch offline.
*/
export function registerServiceWorker(): void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
if (import.meta.env.MODE === 'mock') return;
if (inMiniApp()) return;
void navigator.serviceWorker.register('sw.js').catch(() => {});
}