mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c9dfe9c688
After an update, a tab or a device whose service worker still serves the previous build asks for chunks the update removed. The server answered those requests with index.html and a 200, so the import failed with a MIME error, and everything caching in between was free to keep the HTML as the chunk. A precache install that met a missing file stored it, and Workbox reuses hashed entries by URL, so the device showed "A new version is available" on every load, whatever the reload or the cleared browser cache, and carried the broken entry into every later release that keeps the file's name. The SPA fallback now answers a GET for a build file that is not on disk with a plain 404 (no-store) instead of index.html. A precache install refuses it, so the worker whose cache is complete stays in charge until the new build is. index.html is sent relative to the public dir, which also stops an install living under a dot directory from answering every deep link with a 404. On the client, the reload after a dead chunk (the automatic one and the button on the update screen) no longer goes straight back to the worker that served the broken shell. When that same worker is still in charge and the server answers, its precache and registration are dropped first, so the reload reaches the build the server runs now. Offline, and in offline mode, the shell is kept. Every recovery reload, and the reload of the version handover, asks for the page under a one-off query parameter, so a proxy or CDN that keeps index.html against its headers cannot hand back the stale shell either. A chunk the entry imports statically fails before any of that code runs. shell-guard.js, a small classic script loaded ahead of the entry, watches for exactly that failure and runs the same recovery, once per session. A stylesheet, font or icon that holds a page raises no error at all, so once the app has started and the browser is idle, it looks through the precache for entries that hold a page under a build file's name, fetches each from the server past every cache and puts it back in place. A repaired stylesheet is loaded again, and so are the app's stylesheets when a font was repaired, since the browser does not try a face that failed again in the same document. Only the headers are read, a precache found sound is not looked at again until its entries change, nothing is fetched while nothing is broken, and nothing happens offline, in offline mode or without storage. An answer that is not the file (an auth wall, its login page, a server error) leaves the entry for the next launch. A page the recovery loaded from the network already runs the version the server reports, so the version handover now records it instead of reloading it a second time once the new worker takes over. A page loaded past an older worker that is still in charge, as a hard reload does, still asks for the new worker and records the version only once that has taken over, so the next tab or launch cannot get the old shell without a handover. Dropping the worker ends a Web Push subscription with it, so on such a device the settings card shows push as off until it is switched on again. A missing sw-push.js now fails the worker install with a 404 rather than index.html; the comments that described the old answer say so.
386 lines
18 KiB
JavaScript
386 lines
18 KiB
JavaScript
import react from '@vitejs/plugin-react';
|
|
import { defineConfig } from 'vite';
|
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
import { visualizer } from 'rollup-plugin-visualizer';
|
|
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
// The version this bundle is built as, baked in at build time. The release image
|
|
// bumps every package.json before it builds, so this matches the server's
|
|
// APP_VERSION there; a source checkout matches the server's own package.json.
|
|
// The server hands the release notice only to a bundle built for its version.
|
|
const UI_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
|
|
// `npm run build:analyze` writes dist/stats.html — a treemap of what actually ended
|
|
// up in each chunk. The plain build only reports chunk sizes, which tells you a chunk
|
|
// is too big but not which dependency made it so.
|
|
// Mehrere Worktrees laufen hier parallel. Ohne diese beiden Variablen streiten
|
|
// sie sich um 5173 und 3001; mit ihnen bekommt jeder seinen eigenen Satz.
|
|
const DEV_PORT = Number(process.env.TREK_DEV_PORT) || 5173;
|
|
const API_TARGET = process.env.TREK_DEV_API || 'http://localhost:3001';
|
|
|
|
export default defineConfig(({ mode }) => ({
|
|
define: { __TREK_UI_VERSION__: JSON.stringify(UI_VERSION) },
|
|
plugins: [
|
|
react(),
|
|
mode === 'analyze' &&
|
|
visualizer({ filename: 'dist/stats.html', gzipSize: true, brotliSize: true }),
|
|
VitePWA({
|
|
registerType: 'autoUpdate',
|
|
// Serve the generated manifest (+ dev SW) in development too, so the installed
|
|
// PWA can be tested against the dev server. Without this, dev ships no
|
|
// <link rel="manifest">, so iOS falls back to legacy standalone
|
|
// (apple-mobile-web-app-capable only) and pops the Safari chrome on every
|
|
// in-app navigation away from the start URL. Prod already serves the manifest.
|
|
devOptions: {
|
|
enabled: true,
|
|
type: 'module',
|
|
suppressWarnings: true,
|
|
/*
|
|
* No navigation fallback from the dev service worker.
|
|
*
|
|
* `workbox.navigateFallback` below is right for production: offline, a
|
|
* deep link has to be answered by the cached shell. In development it
|
|
* means the worker answers a reload with the index.html it cached
|
|
* earlier, whose <script> tags point at module URLs from before the last
|
|
* restart — so the browser faithfully re-runs the previous version of
|
|
* the app while the editor shows the new one, and every explanation for
|
|
* that is wrong until someone thinks of the service worker. It cost
|
|
* several rounds of "I don't see the change" to find.
|
|
*/
|
|
navigateFallback: undefined,
|
|
},
|
|
workbox: {
|
|
// The Web Push handlers (push, notificationclick, pushsubscriptionchange).
|
|
// Workbox writes importScripts('sw-push.js') at the top of the generated
|
|
// worker, in dev too. The file lives in public/: were it missing,
|
|
// importScripts would get no script (the server answers a missing build
|
|
// file with a 404) and the new worker would fail to install, which
|
|
// tests/unit/pwa/swPush.test.ts guards against.
|
|
importScripts: ['sw-push.js'],
|
|
// Anything above this is dropped from the precache manifest. The build does
|
|
// not fail over it, it only prints "won't be precached", so the ceiling has
|
|
// to sit close to the real bundle or an accidental heavyweight goes
|
|
// offline-broken unnoticed. Largest precached entry is the heic-to chunk at
|
|
// 3.0 MB, which leaves about 670 kB of headroom; the entry chunk is 258 kB.
|
|
maximumFileSizeToCacheInBytes: 3.5 * 1024 * 1024,
|
|
// Every route chunk is precached alongside the shell, deliberately: for an
|
|
// offline-first travel planner a route the user never opened before losing
|
|
// signal still has to work. The trade is that splitting buys first paint and
|
|
// not install size: 107 entries / 17,795 KiB before any of it, 463 /
|
|
// 23,292 KiB now (measured, not estimated).
|
|
//
|
|
// Keep this figure honest. #2228 traced PWA boot failures to the browser
|
|
// evicting this origin's whole bucket, precached shell included, and this
|
|
// comment is the only record of what the install actually costs. Anything
|
|
// matching the globs below is fetched at service-worker install by every
|
|
// user, whether or not they ever reach the code.
|
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
|
// build:analyze drops a treemap next to the app; it must never end up in a
|
|
// precache manifest if someone ships that build by accident.
|
|
globIgnores: ['**/stats.html'],
|
|
navigateFallback: 'index.html',
|
|
navigateFallbackDenylist: [
|
|
/^\/api/,
|
|
/^\/uploads/,
|
|
/^\/mcp/,
|
|
/^\/oauth\//,
|
|
/^\/.well-known\//,
|
|
/^\/plugin-frame\//,
|
|
],
|
|
runtimeCaching: [
|
|
{
|
|
// Carto map tiles (default provider)
|
|
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
|
|
// (both are 12288) so prefetched tiles aren't evicted on arrival.
|
|
// The apex host counts too: a template without {s} points straight at
|
|
// basemaps.cartocdn.com, and matching only the shards left those tiles
|
|
// uncached, so the map went blank offline.
|
|
urlPattern: /^https:\/\/(?:[a-d]\.)?basemaps\.cartocdn\.com\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'map-tiles',
|
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
// OpenStreetMap tiles (fallback / alternative)
|
|
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
|
|
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
|
|
// Both spellings have to stay in the pattern: templates are rewritten
|
|
// onto the apex host (src/utils/tileUrl.ts), but caches filled before
|
|
// that still hold a/b/c URLs and must keep serving offline.
|
|
urlPattern: /^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'map-tiles',
|
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
// OpenStreetMap DE — a shipped preset that matched no rule at all, so
|
|
// "Store map tiles offline" fetched thousands of tiles and stored none
|
|
// of them (#2180). Same cache, same limits as the rules above.
|
|
urlPattern: /^https:\/\/tile\.openstreetmap\.de\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'map-tiles',
|
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
// Stadia Smooth — the other shipped raster preset with the same hole (#2180).
|
|
urlPattern: /^https:\/\/tiles\.stadiamaps\.com\/tiles\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'map-tiles',
|
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
// Amap road and satellite presets, served from the shard hosts under
|
|
// is.autonavi.com (src/constants/mapDefaults.ts). Without a rule here
|
|
// they would be the #2180 hole all over again.
|
|
urlPattern: /^https:\/\/(?:[a-z0-9]+\.)?is\.autonavi\.com\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'map-tiles',
|
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
// The GL style DOCUMENT, for both providers. It has to be matched
|
|
// before the tile rules below, because Workbox takes the first route
|
|
// that matches and the broad tile patterns cover this URL too (#1924).
|
|
//
|
|
// A style is not a tile. It is one small document that changes the
|
|
// moment the user edits it in Mapbox Studio, and it decides how every
|
|
// tile is drawn — including which `name_*` field the labels read.
|
|
// Under StaleWhileRevalidate the map was built from the PREVIOUS
|
|
// revision on every load, with the refresh landing one load too late,
|
|
// so a style republished with different label languages kept rendering
|
|
// the old ones. The network tab showed a 200 throughout, because the
|
|
// service worker's own answer is a real 200.
|
|
//
|
|
// NetworkFirst rather than NetworkOnly: the map still has to open
|
|
// offline. Its own small cache rather than the tile cache: sharing
|
|
// an LRU with thousands of tiles made eviction, and therefore the
|
|
// staleness, look random.
|
|
urlPattern: /^https:\/\/(api\.mapbox\.com\/styles\/v1\/|tiles\.openfreemap\.org\/styles\/)/i,
|
|
handler: 'NetworkFirst',
|
|
options: {
|
|
cacheName: 'gl-map-styles',
|
|
networkTimeoutSeconds: 5,
|
|
expiration: { maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [200] },
|
|
},
|
|
},
|
|
{
|
|
// Mapbox GL glyphs, sprites and vector tiles (the style itself is
|
|
// handled by the rule above). Best-effort offline only:
|
|
// opportunistically caches what the user has already
|
|
// viewed online. Full pre-download offline maps require the Leaflet
|
|
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
|
|
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
|
|
// fresh online while still serving from cache when offline. Mapbox
|
|
// sends CORS, so responses are non-opaque (real 200s, no quota pad).
|
|
urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i,
|
|
handler: 'StaleWhileRevalidate',
|
|
options: {
|
|
cacheName: 'mapbox-tiles',
|
|
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [200] },
|
|
},
|
|
},
|
|
{
|
|
// OpenFreeMap MapLibre glyphs, sprites and vector tiles (the style
|
|
// itself is handled by the style rule above).
|
|
//
|
|
// CacheFirst, and sharing its cache with the prefetcher: OpenFreeMap
|
|
// serves tiles under a versioned planet path, so a cached tile is
|
|
// never stale — a new planet is a new URL. StaleWhileRevalidate
|
|
// would revalidate every tile of a pre-downloaded region on the next
|
|
// online visit for nothing.
|
|
//
|
|
// cacheName matches sync/glPrefetcher.ts, which writes here directly:
|
|
// one cache means normal browsing tops the offline region up instead
|
|
// of filling a second copy beside it. maxEntries is above the
|
|
// prefetcher's own cap so a browsing session cannot evict a region
|
|
// the user asked to keep.
|
|
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'gl-map-offline',
|
|
expiration: { maxEntries: 6000, maxAgeSeconds: 90 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [200] },
|
|
},
|
|
},
|
|
{
|
|
// API calls — network only. We deliberately do NOT cache API
|
|
// responses in the Service Worker: Workbox keys entries by URL and
|
|
// cannot vary on the httpOnly session cookie, so a shared device
|
|
// could serve one user's cached data to the next (cross-user leak).
|
|
// Offline reads are served from the per-user IndexedDB cache via the
|
|
// repo layer instead. The urlPattern is kept so these requests still
|
|
// bypass the SPA navigation fallback.
|
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
|
handler: 'NetworkOnly',
|
|
},
|
|
{
|
|
// Uploaded files (photos, covers — public assets only)
|
|
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'user-uploads',
|
|
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [200] },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
manifest: {
|
|
name: 'TREK \u2014 Travel Planner',
|
|
short_name: 'TREK',
|
|
description: 'Travel Resource & Exploration Kit',
|
|
theme_color: '#111827',
|
|
background_color: '#0f172a',
|
|
display: 'standalone',
|
|
scope: '/',
|
|
start_url: '/',
|
|
categories: ['travel', 'navigation'],
|
|
icons: [
|
|
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
|
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
|
|
// Dedicated safe-zone renders: the full-bleed icon under a maskable
|
|
// purpose filled the whole Android launcher tile without any padding.
|
|
{ src: 'icons/icon-maskable-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
|
{ src: 'icons/icon-maskable-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
{ src: 'icons/icon.svg', sizes: 'any', type: 'image/svg+xml' },
|
|
],
|
|
},
|
|
}),
|
|
].filter(Boolean),
|
|
resolve: { alias: [rtlTextAlias, plyrSpriteAlias] },
|
|
build: {
|
|
// Pin the output level instead of inheriting whatever the current Vite default
|
|
// is, so a toolchain bump can't silently change which browsers still work.
|
|
target: 'es2022',
|
|
sourcemap: false,
|
|
modulePreload: { polyfill: true },
|
|
// Vite 8 bundles with rolldown, not rollup. `rollupOptions` is only an alias
|
|
// onto `rolldownOptions`, and both `manualChunks` and `advancedChunks` are
|
|
// deprecated in favour of `codeSplitting.groups` — a config mixing them still
|
|
// builds green and simply has no effect.
|
|
//
|
|
// `tags: ['$initial']` is not optional here. Without it a group also collects
|
|
// modules that today hang behind React.lazy, which turns the group chunk into
|
|
// a static import of the entry: measured, that put the 2.8 MB GL chunk into
|
|
// the index.html modulepreload and took eager JS from 1.48 MB to 4.61 MB.
|
|
//
|
|
// Deliberately no groups for mapbox-gl/maplibre-gl, leaflet or react-markdown.
|
|
// Those already sit in async chunks of their own with hashes that survive a
|
|
// release; a group would only rename them, and at worst make them eager.
|
|
rolldownOptions: {
|
|
output: {
|
|
codeSplitting: {
|
|
groups: [
|
|
{
|
|
name: 'vendor-react',
|
|
priority: 40,
|
|
tags: ['$initial'],
|
|
// react-dom/server is pulled dynamically by TripPDF and lives in an
|
|
// async chunk. Excluding it keeps a later static import from lifting
|
|
// ~170 kB of Fizz into the eager vendor chunk.
|
|
test: (id) =>
|
|
/[\\/]node_modules[\\/](react|react-dom|react-router|scheduler)[\\/]/.test(id) &&
|
|
!/react-dom[\\/](server|static)|react-dom-server/.test(id),
|
|
},
|
|
{
|
|
name: 'vendor-core',
|
|
priority: 30,
|
|
// zod and dompurify arrive through @trek/shared but live in
|
|
// node_modules themselves, so they are caught here. @trek/shared is
|
|
// not: it resolves to shared/dist without a node_modules segment,
|
|
// and its contract code changes with every release anyway.
|
|
tags: ['$initial'],
|
|
test: /[\\/]node_modules[\\/](zustand|dexie|axios|zod|dompurify|isomorphic-dompurify)[\\/]/,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
/*
|
|
* Never pre-bundle the workspace's own contracts.
|
|
*
|
|
* `@trek/shared` resolves to shared/dist, which is a build output that changes
|
|
* whenever a Zod schema does. Pre-bundled, the dev server keeps serving the
|
|
* copy it optimised at startup: a field added to the document contract is
|
|
* missing in the browser, `normalizeBookDocument` strips it on load, and the
|
|
* change you just made vanishes on reload with nothing in the console. It
|
|
* cost an afternoon twice before anyone worked out it was not the code.
|
|
*/
|
|
optimizeDeps: {
|
|
exclude: ['@trek/shared'],
|
|
},
|
|
server: {
|
|
port: DEV_PORT,
|
|
// And watch the build output, so rebuilding shared reloads the page rather
|
|
// than leaving a stale module graph behind.
|
|
watch: {
|
|
ignored: ['!**/shared/dist/**'],
|
|
},
|
|
proxy: {
|
|
'/api': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/plugin-frame': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/uploads': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/ws': {
|
|
target: API_TARGET,
|
|
ws: true,
|
|
},
|
|
'/mcp': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
|
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
|
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
|
'/oauth/authorize': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/oauth/token': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/oauth/register': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/oauth/revoke': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
'/.well-known': {
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
}));
|