生产环境用 Service Worker 缓存前端壳和已访问资源

This commit is contained in:
桃树夭 committed 2026-10-08 19:11:10 +08:00
1 parent dcf3b93c2e
commit 94b9a4e9d0
11 files changed
+3187 -12

No files matched your search

+17 -10
View File
@@ -4,16 +4,22 @@
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b tsconfig.json && vite build",
"build": "tsc -b tsconfig.json && tsc --noEmit -p tsconfig.sw.json && vite build",
"dev": "NODE_ENV=development node --env-file=../../env/local.env node_modules/vite/bin/vite.js",
"prd": "NODE_ENV=development node --env-file=../../env/staging.env node_modules/vite/bin/vite.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.sw.json"
},
"dependencies": {
"@better-auth/core": "^1.7.4",
"@better-auth/i18n": "^1.7.4",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "^1.3.1",
"@daoyou/combat-core": "workspace:*",
"@daoyou/constants": "workspace:*",
"@daoyou/contracts": "workspace:*",
"@daoyou/game-content": "workspace:*",
"@daoyou/game-domain": "workspace:*",
"@daoyou/game-rules": "workspace:*",
"altcha": "^3.2.2",
"better-auth": "^1.7.4",
"better-call": "1.4.0",
@@ -31,13 +37,7 @@
"react-router": "^8.3.1",
"react-zoom-pan-pinch": "^4.2.0",
"tailwind-merge": "^3.7.0",
"zod": "^4.6.4",
"@daoyou/constants": "workspace:*",
"@daoyou/game-domain": "workspace:*",
"@daoyou/combat-core": "workspace:*",
"@daoyou/game-content": "workspace:*",
"@daoyou/contracts": "workspace:*",
"@daoyou/game-rules": "workspace:*"
"zod": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
@@ -47,6 +47,13 @@
"@vitejs/plugin-react": "6.1.1",
"graphology-types": "^0.24.8",
"tailwindcss": "^4.3.3",
"vite": "^8.3.0"
"vite": "^8.3.0",
"vite-plugin-pwa": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-window": "7.4.1"
}
}
+3
View File
@@ -1,2 +1,5 @@
/version.json
Cache-Control: no-store
/sw.js
Cache-Control: no-cache
@@ -5,6 +5,7 @@ import {
isNewBuildAvailable,
reloadIntoLatestVersion,
} from '@app/lib/appVersion';
import { checkForServiceWorkerUpdate } from '@app/lib/serviceWorker';
import { useEffect, useRef } from 'react';
const VERSION_CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -29,6 +30,7 @@ export function AppVersionNotifier() {
checkInFlightRef.current = true;
try {
checkForServiceWorkerUpdate();
const latestBuildId = await fetchLatestBuildId();
if (
!active ||
+3 -1
View File
@@ -1,3 +1,5 @@
import { reloadForApplicationUpdate } from './serviceWorker';
export interface AppVersionManifest {
buildId: string;
}
@@ -84,7 +86,7 @@ export function isDynamicImportError(error: unknown): boolean {
}
export function reloadIntoLatestVersion() {
window.location.reload();
reloadForApplicationUpdate();
}
export function recoverFromPreloadError(
+76
View File
@@ -0,0 +1,76 @@
import { Workbox } from 'workbox-window';
const UPDATE_RELOAD_FALLBACK_MS = 3_000;
let workbox: Workbox | undefined;
let registrationPromise: Promise<ServiceWorkerRegistration | undefined> | undefined;
export function registerProductionServiceWorker() {
if (!import.meta.env.PROD || workbox || !('serviceWorker' in navigator)) {
return;
}
const registerOptions: RegistrationOptions = {
scope: '/',
updateViaCache: 'none',
};
workbox = new Workbox('/sw.js', registerOptions);
registrationPromise = workbox.register();
}
export function checkForServiceWorkerUpdate() {
if (!registrationPromise) return;
void registrationPromise
.then(() => workbox?.update())
.catch(() => {
// The version document remains the signal when this check fails.
});
}
export function reloadForApplicationUpdate() {
if (typeof window === 'undefined') return;
const reload = () => {
window.location.reload();
};
if (!workbox || !('serviceWorker' in navigator)) {
reload();
return;
}
let reloaded = false;
const reloadOnce = () => {
if (reloaded) return;
reloaded = true;
reload();
};
const onControllerChange = () => {
reloadOnce();
};
navigator.serviceWorker.addEventListener(
'controllerchange',
onControllerChange,
);
void navigator.serviceWorker
.getRegistration()
.then((registration) => {
if (!registration?.waiting) {
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange,
);
reloadOnce();
return;
}
workbox?.messageSkipWaiting();
window.setTimeout(reloadOnce, UPDATE_RELOAD_FALLBACK_MS);
})
.catch(() => {
reloadOnce();
});
}
+2
View File
@@ -4,10 +4,12 @@ import { RouterProvider } from 'react-router';
import './index.css';
import { registerPreloadErrorRecovery } from './lib/appVersion';
import { initializePwaInstallCapture } from './lib/pwaInstall';
import { registerProductionServiceWorker } from './lib/serviceWorker';
import { router } from './router';
if (import.meta.env.PROD) {
registerPreloadErrorRecovery();
registerProductionServiceWorker();
}
initializePwaInstallCapture();
+197
View File
@@ -0,0 +1,197 @@
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import {
cleanupOutdatedCaches,
precacheAndRoute,
type PrecacheEntry,
} from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
NetworkOnly,
StaleWhileRevalidate,
} from 'workbox-strategies';
declare global {
interface WorkerGlobalScope {
__WB_MANIFEST: Array<PrecacheEntry | string>;
}
}
const scope = self as unknown as ServiceWorkerGlobalScope;
const SHELL_CACHE = 'daoyou-shell';
const DOCUMENT_CACHE = 'daoyou-documents';
const BUILD_ASSET_CACHE = 'daoyou-build-assets';
const MEDIA_CACHE = 'daoyou-media';
const SHELL_URL = '/index.html';
const NAVIGATION_TIMEOUT_SECONDS = 4;
const SHELL_REFRESH_TIMEOUT_MS = 4_000;
const HASHED_BUILD_ASSET =
/^\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.(?:js|css)$/;
// version.json is how the open page detects a new build. API responses carry
// identity and game state, so neither can be replayed from cache.
function isNetworkOnlyPath(url: URL) {
return (
url.pathname === '/version.json' ||
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/internal/')
);
}
function isSameOrigin(url: URL) {
return url.origin === self.location.origin;
}
function isHashedBuildAsset(url: URL) {
return isSameOrigin(url) && HASHED_BUILD_ASSET.test(url.pathname);
}
function isRuntimeMedia(request: Request, url: URL) {
if (request.method !== 'GET' || !isSameOrigin(url) || isNetworkOnlyPath(url)) {
return false;
}
if (isHashedBuildAsset(url)) return false;
return (
request.destination === 'image' ||
request.destination === 'font' ||
request.destination === 'style' ||
url.pathname.startsWith('/assets/') ||
url.pathname.startsWith('/icons/') ||
url.pathname === '/manifest.webmanifest' ||
url.pathname === '/favicon.svg'
);
}
async function storeShell(response: Response) {
if (!response.ok || response.type !== 'basic') return;
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('text/html')) return;
const body = await response.blob();
const cache = await caches.open(SHELL_CACHE);
await cache.put(
SHELL_URL,
new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
);
}
async function refreshShellFromNetwork() {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
SHELL_REFRESH_TIMEOUT_MS,
);
try {
const response = await fetch(SHELL_URL, {
cache: 'reload',
signal: controller.signal,
});
await storeShell(response);
} catch {
// Activation still claims clients when the shell request fails.
} finally {
clearTimeout(timeoutId);
}
}
async function readShell() {
const cache = await caches.open(SHELL_CACHE);
return cache.match(SHELL_URL);
}
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
scope.addEventListener('message', (event) => {
const data: unknown = event.data;
if (
typeof data === 'object' &&
data !== null &&
'type' in data &&
data.type === 'SKIP_WAITING'
) {
void scope.skipWaiting();
}
});
scope.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
await scope.clients.claim();
await refreshShellFromNetwork();
})(),
);
});
registerRoute(
({ url }) => isSameOrigin(url) && isNetworkOnlyPath(url),
new NetworkOnly(),
);
registerRoute(
({ request, url }) => request.method === 'GET' && isHashedBuildAsset(url),
new CacheFirst({
cacheName: BUILD_ASSET_CACHE,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({
maxEntries: 400,
purgeOnQuotaError: true,
}),
],
}),
);
registerRoute(
({ request, url }) => isRuntimeMedia(request, url),
new StaleWhileRevalidate({
cacheName: MEDIA_CACHE,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({
maxEntries: 100,
purgeOnQuotaError: true,
}),
],
}),
);
const navigationStrategy = new NetworkFirst({
cacheName: DOCUMENT_CACHE,
networkTimeoutSeconds: NAVIGATION_TIMEOUT_SECONDS,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({
maxEntries: 8,
purgeOnQuotaError: true,
}),
],
});
registerRoute(
({ request, url }) =>
request.mode === 'navigate' &&
isSameOrigin(url) &&
!isNetworkOnlyPath(url),
async (options) => {
try {
const response = await navigationStrategy.handle(options);
if (response.ok) {
options.event.waitUntil(storeShell(response.clone()));
}
return response;
} catch {
const shell = await readShell();
if (shell) return shell;
return Response.error();
}
},
);
+1
View File
@@ -14,6 +14,7 @@
},
"include": ["src"],
"exclude": [
"src/sw.ts",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts"
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"noEmit": true,
"types": []
},
"include": ["src/sw.ts"]
}
+27 -1
View File
@@ -2,6 +2,7 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig, type Plugin, type UserConfig } from 'vite';
const alias = {
@@ -29,7 +30,32 @@ export default defineConfig((): UserConfig => {
envDir: false,
resolve: { alias },
define: { __APP_BUILD_ID__: JSON.stringify(buildId) },
plugins: [react(), tailwindcss(), appVersionManifestPlugin(buildId)],
plugins: [
react(),
tailwindcss(),
appVersionManifestPlugin(buildId),
...VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: false,
manifest: false,
devOptions: { enabled: false },
injectManifest: {
rollupFormat: 'iife',
// Public images live under assets/ without a content hash.
dontCacheBustURLsMatching:
/^assets\/[^/]+-[A-Za-z0-9_-]{8,}\.(?:js|css)$/,
globPatterns: [
'manifest.webmanifest',
'favicon.svg',
'icons/*.png',
'assets/paper.webp',
'assets/app-boot/*.{svg,webp}',
],
},
}),
],
build: { outDir: 'dist', emptyOutDir: true },
server: {
host: process.env.HOST ?? '127.0.0.1',
+2850
View File
File diff suppressed because it is too large. Load diff