生产环境用 Service Worker 缓存前端壳和已访问资源
This commit is contained in:
1 parent
dcf3b93c2e
commit
94b9a4e9d0
11 files changed
+3187
-12
No files matched your search
+17
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"src/sw.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts"]
|
||||
}
|
||||
+27
-1
@@ -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',
|
||||
|
||||
Generated
+2850
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user