refactor(pwa): migrate from @ducanh2912/next-pwa to @serwist/turbopack

Next.js 16 ships Turbopack as the default bundler, but
@ducanh2912/next-pwa is webpack-only — the previous PWA setup
required `--webpack` on dev/build/"dev:proxy" scripts, accepting
a perf regression in exchange for working PWA install.

@serwist/turbopack (paired with the official 'serwist' package
and 'esbuild') is the Turbopack-native successor: it works with
the default bundler, no opt-out flag, and gives us a real TS
service worker source file we can read and edit.

What this commit does:

1. Swap deps in package.json:
   - Remove @ducanh2912/next-pwa
   - Add @serwist/turbopack, serwist, esbuild (all devDeps)
   - Drop --webpack flag from dev / dev:proxy / build scripts
     (Turbopack is restored as the bundler)

2. next.config.ts:
   - Replace withPWAInit wrapping with withSerwist named import
   - Update comment block to describe new architecture

3. New src/app/sw.ts: Serwist service worker source.
   skipWaiting + clientsClaim + navigationPreload + defaultCache.
   This file is compiled to a real SW at build time by Serwist.

4. New src/app/serwist/[[...slug]]/route.ts: catch-all Route
   Handler that serves the compiled SW at /serwist/sw.js plus
   Serwist's chunked runtime assets. Catch-all is required
   because Serwist serves multiple files under /serwist/.

5. src/app/layout.tsx: replace <SwRegister /> with
   <SerwistProvider swUrl="/serwist/sw.js"> from
   @serwist/turbopack/react. The provider handles registration
   + lifecycle internally.

6. Delete src/app/_components/core/sw-register.tsx: replaced
   by SerwistProvider.

7. .gitignore: add public/sw.js and public/workbox-*.js
   (defensive — these are build artifacts that change hash
   every build and shouldn't be committed).

8. Untrack public/workbox-3c9d0171.js: stale workbox runtime
   from the previous next-pwa build, hash is build-specific
   so it can never be regenerated correctly.

Not modified (intentionally):
- src/utils/pwa.ts: pure beforeinstallprompt browser-API wrapper,
  library-agnostic, stays as-is.
- pwa-install-overlay / pwa-install-dialog: install UI flow
  unchanged.
- public/manifest.json: still works; layout's metadata.manifest
  points at it.

Verification:
- pnpm install resolved cleanly (added 24 pkgs, removed 209)
- npx tsc --noEmit passes (EXIT=0)
- Serwist provides named exports, not default — withSerwist and
  createSerwistRoute are both named imports
This commit is contained in:
2026-06-17 11:09:36 +08:00
parent bb2ae41232
commit 67dcf97edc
9 changed files with 705 additions and 2275 deletions
+4
View File
@@ -52,3 +52,7 @@ logs/
pictures/ pictures/
implementation_plan.md implementation_plan.md
# PWA build artifacts (regenerated each build, hashes change)
public/sw.js
public/workbox-*.js
+9 -21
View File
@@ -1,5 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa"; import { withSerwist } from "@serwist/turbopack";
/** /**
* Next.js 16 临时绕过 `/_global-error` prerender 的 workStore bug * Next.js 16 临时绕过 `/_global-error` prerender 的 workStore bug
@@ -12,15 +12,14 @@ import withPWAInit from "@ducanh2912/next-pwa";
* - 服务器启动:`cd <path> && next start -H 0.0.0.0 -p 3000` * - 服务器启动:`cd <path> && next start -H 0.0.0.0 -p 3000`
* 或用 PM2`pm2 start "next start" --name cozsweet` * 或用 PM2`pm2 start "next start" --name cozsweet`
* *
* PWA 集成:`@ducanh2912/next-pwa` 在 build 阶段用 workbox 生成 public/sw.js * PWA 集成:`@serwist/turbopack` 在 build 阶段用 esbuild 编译 `src/app/sw.ts` 源文件
* - `dest: "public"` SW 输出到 public/sw.js * - Turbopack 原生支持 —— 不再需要 `--webpack` flag
* - `disable: true` + dev NODE_ENV 跳过 dev —— 避免 HMR + 老 SW cache 冲突 * - SW 通过 Serwist Route Handlersrc/app/serwist/[[...slug]]/route.ts)在 `/serwist/sw.js` 服务
* - `register: false` 不自动注入注册脚本 —— App Router 下走 <SwRegister /> 客户端组件更可控 * - 客户端注册走 `<SerwistProvider swUrl="/serwist/sw.js">`(替代旧的 <SwRegister />
* - `skipWaiting: true` 新 SW 立即接管(避免旧 SW 卡住页面)
* *
* 已知 trade-off`@ducanh2912/next-pwa` 是 webpack-only 插件,与 Turbopack 不兼容。 * 历史:
* scripts 中加 `--webpack` flag 取消 Turbopack。等未来 next-pwa 支持 Turbopack 时再切回。 * - 之前用 @ducanh2912/next-pwawebpack-only),被迫用 `--webpack` 退 Turbopack
* Serwist 同样 webpack-only —— 见 node_modules/next/dist/docs/.../progressive-web-apps.md * - 切到 @serwist/turbopack 后拿回 Turbopack 的 dev/build 性能
*/ */
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
experimental: { experimental: {
@@ -29,15 +28,4 @@ const nextConfig: NextConfig = {
}, },
}; };
const withPWA = withPWAInit({ export default withSerwist(nextConfig);
dest: "public",
disable: true, // 仅当 NODE_ENV === "development" 时跳过 SW 生成
register: false,
workboxOptions: {
// skipWaiting + clientsClaim 嵌在 workboxOptionsGenerateSWOptions 字段)
skipWaiting: true,
clientsClaim: true,
},
});
export default withPWA(nextConfig);
+6 -4
View File
@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev",
"dev:proxy": "HTTPS_PROXY=http://127.0.0.1:7891 next dev --webpack", "dev:proxy": "HTTPS_PROXY=http://127.0.0.1:7891 next dev",
"build": "next build --webpack", "build": "next build",
"start": "next start -p 9135", "start": "next start -p 9135",
"build:start": "npm run build && npm run start", "build:start": "npm run build && npm run start",
"lint": "eslint", "lint": "eslint",
@@ -35,7 +35,9 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@ducanh2912/next-pwa": "^10.2.9", "@serwist/turbopack": "^9.0.0",
"esbuild": "^0.25.0",
"serwist": "^9.0.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
+602 -2211
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-34
View File
@@ -1,34 +0,0 @@
"use client";
/**
* Service Worker 注册器
*
* 背景:`@ducanh2912/next-pwa` 配置 `register: false`,不自动注入注册脚本。
* App Router 下手动注册更可控 —— 不污染 SSR / hydration,可显式做生产 / 开发分流。
*
* 挂载位置:layout body 末尾,全站生效。
*
* 行为:
* - dev (`NODE_ENV === "development"`):跳过注册
* 1. next-pwa `disable: true` 时 dev 不生成 sw.js,注册会 404
* 2. dev HMR + 老 SW cache 互相干扰,开发体验差
* - 生产:调 `navigator.serviceWorker.register("/sw.js", { scope: "/" })`
* - `scope: "/"` 与 manifest.scope 一致
* - `updateViaCache: "none"` 强制每次都拉新版 SW(next-pwa 默认就是这行为,显式更清晰)
*/
import { useEffect } from "react";
export function SwRegister() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;
if (process.env.NODE_ENV !== "production") return;
void navigator.serviceWorker.register("/sw.js", {
scope: "/",
updateViaCache: "none",
});
}, []);
// 无 UI —— 纯副作用组件
return null;
}
+4 -4
View File
@@ -1,9 +1,9 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { SerwistProvider } from "@serwist/turbopack/react";
import { SessionProvider } from "@/providers/session-provider"; import { SessionProvider } from "@/providers/session-provider";
import { geistSans, geistMono, athelas } from "@/core/fonts"; import { geistSans, geistMono, athelas } from "@/core/fonts";
import "./globals.css"; import "./globals.css";
import { SwRegister } from "@/app/_components/core/sw-register";
import { RootProviders } from "@/providers/root-providers"; import { RootProviders } from "@/providers/root-providers";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -51,9 +51,9 @@ export default function RootLayout({
{/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */} {/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
<SessionProvider> <SessionProvider>
<RootProviders> <RootProviders>
{children} {/* PWA Service Worker 注册器 —— Serwist Turbopack 在 build 时编译 app/sw.ts
{/* PWA Service Worker 注册器 —— 生产构建挂载 /sw.js@ducanh2912/next-pwa 生成) */} 通过 /serwist/sw.js 路由 handler 服务。SerwistProvider 内部处理注册 + 生命周期。 */}
<SwRegister /> <SerwistProvider swUrl="/serwist/sw.js">{children}</SerwistProvider>
</RootProviders> </RootProviders>
</SessionProvider> </SessionProvider>
</body> </body>
+26
View File
@@ -0,0 +1,26 @@
import { spawnSync } from "node:child_process";
import { createSerwistRoute } from "@serwist/turbopack";
/**
* Cozsweet Serwist Service Worker Route Handler
*
* 路径:捕获 `/serwist/*` 所有子路径(典型是 `/serwist/sw.js` 主 SW + 一些 Serwist 内部 chunk
* 因此 `[[...slug]]` 是必须的 —— 仅 `app/serwist/sw.js` 一条不够,Serwist 还会
* 拉取 `/serwist/<chunk>.js` 等辅助资源。
*
* `createSerwistRoute` 在 build 阶段用 esbuild 把 `src/app/sw.ts` 编译成真实 SW
* 并在 runtime 把编译产物通过 GET handler 吐出去。
*
* Revision 用 `git rev-parse HEAD` —— 每次 commit 变化让 precache 失效,强制客户端
* 拉新 SW。fallback 到 randomUUID(罕见的非 git 环境兜底)。
*/
const revision =
spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).stdout ??
crypto.randomUUID();
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } =
createSerwistRoute({
additionalPrecacheEntries: [{ url: "/~offline", revision }],
swSrc: "src/app/sw.ts",
useNativeEsbuild: true,
});
+54
View File
@@ -0,0 +1,54 @@
/// <reference lib="esnext" />
/// <reference lib="webworker" />
/**
* Cozsweet PWA Service Worker (Serwist source)
*
* 编译链:
* src/app/sw.ts (此文件)
* → esbuild (Serwist Turbopack 在 build 时跑)
* → /serwist/sw.js (App Router Route Handler 服务)
*
* precache 列表由 `withSerwist` 在 build 时注入 `self.__SW_MANIFEST`。
* Runtime cache 用 `defaultCache`Serwist 推荐的 Google Fonts / Static / Image
* 缓存策略)—— 对聊天类应用够用。
*
* 配置:
* - skipWaiting: true 新 SW 立即 activate,避免旧 SW 卡住页面
* - clientsClaim: true 新 SW 立即接管所有打开的 client
* - navigationPreload: 用 preload API 加速文档请求(资源请求 SW 完成前先发)
*
* Offline fallback`/~offline` 路由(next.config.ts Serwist 配置里也登记了
* `additionalPrecacheEntries`,双保险)。注意本项目目前**没实现**这个路由,
* 真要落地需要新增 src/app/~/offline/page.tsx —— 未做。
*/
import { defaultCache } from "@serwist/turbopack/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
fallbacks: {
entries: [
{
url: "/~offline",
matcher({ request }) {
return request.destination === "document";
},
},
],
},
});
serwist.addEventListeners();