feat(web): migrate PWA to Serwist (#30808)

This commit is contained in:
Stephen Zhou
2026-01-10 16:16:18 +08:00
committed by GitHub
parent 0711dd4159
commit 8b1af36d94
9 changed files with 505 additions and 884 deletions
+3
View File
@@ -0,0 +1,3 @@
'use client'
export { SerwistProvider } from '@serwist/turbopack/react'
+33 -27
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/base/toast'
import BrowserInitializer from './components/browser-initializer' import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader' import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server' import { I18nServerProvider } from './components/provider/i18n-server'
import { SerwistProvider } from './components/provider/serwist'
import SentryInitializer from './components/sentry-initializer' import SentryInitializer from './components/sentry-initializer'
import RoutePrefixHandle from './routePrefixHandle' import RoutePrefixHandle from './routePrefixHandle'
import './styles/globals.css' import './styles/globals.css'
@@ -39,6 +40,9 @@ const LocaleLayout = async ({
}) => { }) => {
const locale = await getLocaleOnServer() const locale = await getLocaleOnServer()
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
const swUrl = `${basePath}/serwist/sw.js`
const datasetMap: Record<DatasetAttr, string | undefined> = { const datasetMap: Record<DatasetAttr, string | undefined> = {
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX, [DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
[DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, [DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
@@ -92,33 +96,35 @@ const LocaleLayout = async ({
className="color-scheme h-full select-auto" className="color-scheme h-full select-auto"
{...datasetMap} {...datasetMap}
> >
<ReactScanLoader /> <SerwistProvider swUrl={swUrl}>
<JotaiProvider> <ReactScanLoader />
<ThemeProvider <JotaiProvider>
attribute="data-theme" <ThemeProvider
defaultTheme="system" attribute="data-theme"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
enableColorScheme={false} disableTransitionOnChange
> enableColorScheme={false}
<NuqsAdapter> >
<BrowserInitializer> <NuqsAdapter>
<SentryInitializer> <BrowserInitializer>
<TanstackQueryInitializer> <SentryInitializer>
<I18nServerProvider> <TanstackQueryInitializer>
<ToastProvider> <I18nServerProvider>
<GlobalPublicStoreProvider> <ToastProvider>
{children} <GlobalPublicStoreProvider>
</GlobalPublicStoreProvider> {children}
</ToastProvider> </GlobalPublicStoreProvider>
</I18nServerProvider> </ToastProvider>
</TanstackQueryInitializer> </I18nServerProvider>
</SentryInitializer> </TanstackQueryInitializer>
</BrowserInitializer> </SentryInitializer>
</NuqsAdapter> </BrowserInitializer>
</ThemeProvider> </NuqsAdapter>
</JotaiProvider> </ThemeProvider>
<RoutePrefixHandle /> </JotaiProvider>
<RoutePrefixHandle />
</SerwistProvider>
</body> </body>
</html> </html>
) )
+14
View File
@@ -0,0 +1,14 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { createSerwistRoute } from '@serwist/turbopack'
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || randomUUID()
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
additionalPrecacheEntries: [{ url: `${basePath}/_offline.html`, revision }],
swSrc: 'app/sw.ts',
nextConfig: {
basePath,
},
})
+104
View File
@@ -0,0 +1,104 @@
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { CacheableResponsePlugin, CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist'
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: ServiceWorkerGlobalScope
const scopePathname = new URL(self.registration.scope).pathname
const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '')
const offlineUrl = `${basePath}/_offline.html`
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [
{
matcher: ({ url }) => url.origin === 'https://fonts.googleapis.com',
handler: new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ url }) => url.origin === 'https://fonts.gstatic.com',
handler: new CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ request }) => request.destination === 'image',
handler: new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ request }) => request.destination === 'script' || request.destination === 'style',
handler: new StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new ExpirationPlugin({
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'),
handler: new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
plugins: [
new ExpirationPlugin({
maxEntries: 16,
maxAgeSeconds: 60 * 60,
}),
],
}),
},
],
fallbacks: {
entries: [
{
url: offlineUrl,
matcher({ request }) {
return request.destination === 'document'
},
},
],
},
})
serwist.addEventListeners()
+1 -4
View File
@@ -15,10 +15,7 @@ const config: KnipConfig = {
ignoreBinaries: [ ignoreBinaries: [
'only-allow', 'only-allow',
], ],
ignoreDependencies: [ ignoreDependencies: [],
// required by next-pwa
'babel-loader',
],
rules: { rules: {
files: 'warn', files: 'warn',
dependencies: 'warn', dependencies: 'warn',
+2 -70
View File
@@ -1,77 +1,8 @@
import withBundleAnalyzerInit from '@next/bundle-analyzer' import withBundleAnalyzerInit from '@next/bundle-analyzer'
import createMDX from '@next/mdx' import createMDX from '@next/mdx'
import { codeInspectorPlugin } from 'code-inspector-plugin' import { codeInspectorPlugin } from 'code-inspector-plugin'
import withPWAInit from 'next-pwa'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
const withPWA = withPWAInit({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
fallbacks: {
document: '/_offline.html',
},
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
{
urlPattern: /\.(?:js|css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /^\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 16,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
],
})
const withMDX = createMDX({ const withMDX = createMDX({
extension: /\.mdx?$/, extension: /\.mdx?$/,
options: { options: {
@@ -97,6 +28,7 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '', basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
serverExternalPackages: ['esbuild-wasm'],
transpilePackages: ['echarts', 'zrender'], transpilePackages: ['echarts', 'zrender'],
turbopack: { turbopack: {
rules: codeInspectorPlugin({ rules: codeInspectorPlugin({
@@ -148,4 +80,4 @@ const nextConfig = {
}, },
} }
export default withPWA(withBundleAnalyzer(withMDX(nextConfig))) export default withBundleAnalyzer(withMDX(nextConfig))
+3 -3
View File
@@ -111,7 +111,6 @@
"mitt": "^3.0.1", "mitt": "^3.0.1",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "~15.5.9", "next": "~15.5.9",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.8.6", "nuqs": "^2.8.6",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
@@ -153,7 +152,6 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.7.3", "@antfu/eslint-config": "^6.7.3",
"@babel/core": "^7.28.4",
"@chromatic-com/storybook": "^4.1.1", "@chromatic-com/storybook": "^4.1.1",
"@eslint-react/eslint-plugin": "^2.3.13", "@eslint-react/eslint-plugin": "^2.3.13",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
@@ -162,6 +160,7 @@
"@next/eslint-plugin-next": "15.5.9", "@next/eslint-plugin-next": "15.5.9",
"@next/mdx": "15.5.9", "@next/mdx": "15.5.9",
"@rgrove/parse-xml": "^4.2.0", "@rgrove/parse-xml": "^4.2.0",
"@serwist/turbopack": "^9.5.0",
"@storybook/addon-docs": "9.1.13", "@storybook/addon-docs": "9.1.13",
"@storybook/addon-links": "9.1.13", "@storybook/addon-links": "9.1.13",
"@storybook/addon-onboarding": "9.1.13", "@storybook/addon-onboarding": "9.1.13",
@@ -194,9 +193,9 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"code-inspector-plugin": "1.2.9", "code-inspector-plugin": "1.2.9",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"esbuild-wasm": "^0.27.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
@@ -212,6 +211,7 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react-scan": "^0.4.3", "react-scan": "^0.4.3",
"sass": "^1.93.2", "sass": "^1.93.2",
"serwist": "^9.5.0",
"storybook": "9.1.17", "storybook": "9.1.17",
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.18",
"tsx": "^4.21.0", "tsx": "^4.21.0",
+345 -779
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long