Merge pull request 'feat(offline): app-shell precache service worker (vite-plugin-pwa) [C1]' (#197) from feature/offline-pwa-precache into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
This commit was merged in pull request #197.
This commit is contained in:
@@ -1238,12 +1238,15 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
|
|||||||
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||||
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||||
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
no network, and falls every in-scope navigation back to the precached shell (the hash router
|
||||||
growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded,
|
||||||
|
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is
|
||||||
|
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||||
|
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||||
an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the
|
an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the
|
||||||
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`,
|
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`,
|
||||||
|
|||||||
+4
-3
@@ -8,9 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
|||||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
|
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||||
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
|
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||||
install-only `sw.js`, both shipped unhashed from `ui/public/`.
|
`/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
|
||||||
|
precaches the shell + assets so an installed PWA cold-launches offline).
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
|||||||
+5
-1
@@ -32,6 +32,10 @@
|
|||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
"workbox-core": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3195
-21
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
|||||||
// Install-only service worker.
|
|
||||||
//
|
|
||||||
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
|
|
||||||
// service worker with a fetch handler) so the web app can be installed to the home screen /
|
|
||||||
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
|
|
||||||
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
|
|
||||||
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
|
|
||||||
// the live event stream are never served stale.
|
|
||||||
//
|
|
||||||
// This is the single designated growth point for the planned offline mode (a Settings toggle,
|
|
||||||
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
|
|
||||||
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
|
|
||||||
// version guard.
|
|
||||||
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
|
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
|
||||||
// No cached content to migrate carefully, so activate the new worker immediately.
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
|
|
||||||
await self.clients.claim();
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
|
|
||||||
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
|
|
||||||
// can never break the live stream or serve a stale immutable asset.
|
|
||||||
if (req.mode !== 'navigate') return;
|
|
||||||
event.respondWith(
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(req);
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
// One shell entry regardless of the deep-link path: the hash router resolves the route
|
|
||||||
// client-side, so any offline navigation can be served the same cached shell.
|
|
||||||
cache.put('shell', res.clone());
|
|
||||||
return res;
|
|
||||||
} catch {
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
const cached = await cache.match('shell');
|
|
||||||
return cached ?? Response.error();
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -76,9 +76,11 @@ export async function promptInstall(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in
|
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
|
||||||
* the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini
|
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
|
||||||
* App. Best-effort — a failed registration only means the app is not installable.
|
* 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 {
|
export function registerServiceWorker(): void {
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
// Offline app-shell service worker (vite-plugin-pwa, injectManifest strategy). It precaches the app
|
||||||
|
// shell and its hashed assets so the installed PWA cold-launches with no network, and falls every
|
||||||
|
// in-scope navigation back to the precached shell (the hash router resolves the route client-side,
|
||||||
|
// so any deep link boots offline). It supersedes the former install-only public/sw.js. The
|
||||||
|
// Connect-RPC stream and the runtime API calls are POSTs — never precached, never matched by the
|
||||||
|
// navigation route — so the live app, its immutable hashed assets and the event stream are never
|
||||||
|
// served stale. Registration stays manual and web-only (pwa.svelte.ts): skipped in the mock build
|
||||||
|
// and inside a Telegram/VK Mini App.
|
||||||
|
import { clientsClaim } from 'workbox-core';
|
||||||
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
|
||||||
|
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||||
|
|
||||||
|
// The injectManifest build replaces self.__WB_MANIFEST with the precache list; the DOM lib knows
|
||||||
|
// neither it nor the service-worker scope, so narrow self here.
|
||||||
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
|
__WB_MANIFEST: (string | { url: string; revision: string | null })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activate a new revision immediately and take control of open clients, matching the former SW's
|
||||||
|
// skipWaiting; the no-cache HTML shell + immutable hashed assets keep a mid-session update safe.
|
||||||
|
self.skipWaiting();
|
||||||
|
clientsClaim();
|
||||||
|
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
|
||||||
|
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Serve the precached shell for any in-scope top-level navigation. Deny-list the RPC path and the
|
||||||
|
// admin console so those are never resolved to the app shell.
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(createHandlerBoundToURL('index.html'), {
|
||||||
|
denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//],
|
||||||
|
}),
|
||||||
|
);
|
||||||
+27
-1
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
|
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
|
||||||
@@ -56,7 +57,32 @@ export default defineConfig(({ mode }) => ({
|
|||||||
},
|
},
|
||||||
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
|
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
|
||||||
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
|
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
|
||||||
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
|
plugins: [
|
||||||
|
svelte(),
|
||||||
|
emitPolyfills(),
|
||||||
|
injectBootVersion(),
|
||||||
|
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
|
||||||
|
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It
|
||||||
|
// supersedes the former install-only public/sw.js and outputs to dist/sw.js (same path, so the
|
||||||
|
// existing /app/ registration is unchanged). Registration is manual + web-only (injectRegister
|
||||||
|
// off; see pwa.svelte.ts) and the whole plugin is disabled in the mock build so Playwright is
|
||||||
|
// never perturbed. manifest:false — we already ship public/manifest.webmanifest.
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
injectRegister: false,
|
||||||
|
manifest: false,
|
||||||
|
disable: mode === 'mock',
|
||||||
|
injectManifest: {
|
||||||
|
// The app shell only: exclude the separate landing page and the conditional polyfill bundle
|
||||||
|
// (loaded document.write-only on an old engine; never part of the offline shell).
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2}'],
|
||||||
|
globIgnores: ['**/landing*', '**/polyfills.js'],
|
||||||
|
},
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
Reference in New Issue
Block a user