release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
@@ -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,
|
||||
`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**:
|
||||
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
||||
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
||||
growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||
**precaches the app shell and the hashed assets** (Workbox) so an 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); 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
|
||||
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`,
|
||||
|
||||
+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
|
||||
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.
|
||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
|
||||
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
|
||||
install-only `sw.js`, both shipped unhashed from `ui/public/`.
|
||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||
`/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
|
||||
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
|
||||
|
||||
+5
-1
@@ -32,6 +32,10 @@
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.7.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
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
@@ -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 { defineConfig, type Plugin } from 'vite';
|
||||
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
|
||||
@@ -56,7 +57,32 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
// 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.
|
||||
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: {
|
||||
port: 5173,
|
||||
proxy:
|
||||
|
||||
Reference in New Issue
Block a user