8000 fix(pwa): disable navigation preload and serve cached static app shell on handoff by InfinityPacer · Pull Request #79 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ docker compose up -d --no-build --pull never

完成后用 `docker compose ps` 检查状态,默认访问 `http://127.0.0.1:4318`。镜像以 Bun 用户运行,挂载目录必须可写;不要让多个 Meterleaf 进程共用同一账本。发布流程见[版本与发布](releasing.md)。

生产构建包含 PWA 清单、图标和离线页,浏览器可将应用安装为独立窗口,无需额外安装插件。首页在脚本加载和慢网连接期间显示启动提示,连接超时或断网后自动切为离线提示并在恢复后重新打开账本,构建还会为文本静态资源生成 Brotli/Gzip 变体并按浏览器能力返回。除本机地址外需要 HTTPS,反向代理须允许访问 `/manifest.webmanifest`、`/sw.js`、`/offline.html` 和 `/icons/*`。离线资源不缓存 API 或账户数据,断网后重新打开不会展示旧账本。
生产构建包含 PWA 清单、图标、静态前端壳缓存和离线兜底页,浏览器可将应用安装为独立窗口,无需额外安装插件。离线脚本禁用浏览器导航预加载,避免移动网络切换时挂死的预加载请求堵住同源连接池,在首次导航连接超时或断网时立即中止旧连接并回退到本地纯静态前端壳(尚未缓存前端壳的首次安装则回退到离线页),网络恢复后自动重读账本,构建还会为文本静态资源生成 Brotli/Gzip 变体并按浏览器能力返回。除本机地址外需要 HTTPS,反向代理须允许访问 `/manifest.webmanifest`、`/sw.js`、`/offline.html` 和 `/icons/*`。离线资源绝不缓存 `/api/*` 或任何账户与账本数据,断网后重新打开不会展示旧账本。

随附 Compose 仅绑定宿主机回环地址。如果反向代理在其他容器中,应加入共享 Docker 网络并访问 `meterleaf:4318`,或显式调整宿主机绑定地址。对外开放前按[GitHub 登录](#github-登录)开启登录保护。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.16.6",
"version": "0.16.7",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
194 changes: 170 additions & 24 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
const CACHE_NAME = "meterleaf-offline-v7";
const CACHE_NAME = "meterleaf-offline-v8";
const SHELL_CACHE_PREFIX = "meterleaf-shell-";
const SHELL_CACHE_NAME = "meterleaf-shell-dev";
const SHELL_INDEX_URL = "/";
const OFFLINE_URL = "/offline.html";
const SHELL_ASSETS = [];
const HASHED_ASSET_PATH =
/^\/assets\/.+[-_][A-Za-z0-9_-]{8,}\.(?:js|css|woff2)$/;
/**
* 首次页面导航的快速回退上限。网络切换或弱网握手挂住时,旧连接可能既不失败也不返回,
* 超过这一时间仍未拿到响应首包就先交出本地启动过渡页,避免独立窗口长时间停在白屏。
* 超过这一时间仍未拿到响应首包就中止卡死的连接并交出本地纯静态前端壳,避免白屏与连接池阻塞。
*/
const INITIAL_NAVIGATION_TIMEOUT_MS = 350;
/**
* 已确认服务可达后的重载,以及登录回调等认证跳转使用的完整超时上限。
* 此时窗口已有连接提示画面,不再白屏,需要留足慢网传输和 OAuth 会话交换时间。
* 重新登录、已确认服务可达后的重载,以及登录回调等认证跳转使用的完整超时上限。
* 此时窗口已有界面提示,需要留足慢网传输和 OAuth 会话交换时间。
*/
const CONFIRMED_NAVIGATION_TIMEOUT_MS = 8000;
let confirmedNavigateUntil = 0;
let warmingShell = null;
const PRECACHE_URLS = [
OFFLINE_URL,
// 品牌图标在登录过期时也要可读,否则认证网关的重定向会让页面显示破图。
Expand All @@ -24,6 +31,87 @@ const PRECACHE_URLS = [
"/icons/meterleaf-512.png",
];

function isHtmlShellResponse(response) {
return (
Boolean(response) &&
response.status === 200 &&
!response.redirected &&
response.type !== "opaqueredirect" &&
(response.headers.get("content-type") ?? "").includes("text/html")
);
}

function isSessionRedirect(response) {
return (
Boolean(response) &&
(response.type === "opaqueredirect" ||
response.redirected ||
response.status === 401 ||
response.status === 403 ||
(response.status >= 300 && response.status < 400))
);
}

function warmShellAssets() {
if (warmingShell) return warmingShell;
warmingShell = (async () => {
try {
const cache = await caches.open(SHELL_CACHE_NAME);
if (!(await cache.match(SHELL_INDEX_URL))) {
const indexResponse = await fetch(
new Request(SHELL_INDEX_URL, {
cache: "reload",
credentials: "same-origin",
redirect: "manual",
}),
);
if (!isHtmlShellResponse(indexResponse)) return false;
await cache.put(SHELL_INDEX_URL, indexResponse);
}
await Promise.all(
SHELL_ASSETS.map(async (assetUrl) => {
if (await cache.match(assetUrl)) return;
const response = await fetch(
new Request(assetUrl, {
credentials: "same-origin",
redirect: "manual",
}),
);
if (
response.status === 200 &&
!response.redirected &&
response.type !== "opaqueredirect"
) {
await cache.put(assetUrl, response);
}
}),
);
return true;
} catch {
return false;
} finally {
warmingShell = null;
}
})();
return warmingShell;
}

async function matchNavigationFallback() {
const shellCache = await caches.open(SHELL_CACHE_NAME);
const shellIndex = await shellCache.match(SHELL_INDEX_URL);
if (shellIndex) {
const missingAsset = (
await Promise.all(
SHELL_ASSETS.map(
async (assetUrl) => !(await shellCache.match(assetUrl)),
),
)
).some(Boolean);
if (!missingAsset) return shellIndex;
}
return caches.match(OFFLINE_URL);
}

self.addEventListener("install", (event) => {
event.waitUntil(
caches
Expand All @@ -40,17 +128,30 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if ("navigationPreload" in self.registration)
await self.registration.navigationPreload.enable();
// 显式关闭旧版本启用的导航预加载。iOS WebKit 在 Wi-Fi 切蜂窝网时会在失效连接上
// 挂住底层预加载请求且无法被 AbortController 中止,并堵住同源 HTTP/2 连接池。
if ("navigationPreload" in self.registration) {
await self.registration.navigationPreload
.disable()
.catch(() => undefined);
}
const keys = await caches.keys();
await Promise.all(
keys
.filter(
(key) => key.startsWith("meterleaf-offline-") && key !== CACHE_NAME,
(key) =>
(key.startsWith("meterleaf-offline-") && key !== CACHE_NAME) ||
(key.startsWith(SHELL_CACHE_PREFIX) && key !== SHELL_CACHE_NAME),
)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
if (
self.clients.matchAll &&
(await self.clients.matchAll({ type: "window" })).length > 0
) {
await warmShellAssets();
}
})(),
);
});
Expand All @@ -59,28 +160,58 @@ self.addEventListener("message", (event) => {
if (event.data?.type === "meterleaf-prepare-navigate") {
confirmedNavigateUntil = Date.now() + CONFIRMED_NAVIGATION_TIMEOUT_MS;
event.ports[0]?.postMessage(true);
} else if (event.data?.type === "meterleaf-warm-shell") {
const done = warmShellAssets().then((ok) => {
event.ports[0]?.postMessage(ok);
});
if (typeof event.waitUntil === "function") event.waitUntil(done);
}
});

self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
// 离线页的图标同样必须可读;账本与 API 不进入静态资源白名单。
if (
url.origin === self.location.origin &&
PRECACHE_URLS.includes(url.pathname)
) {
if (url.origin !== self.location.origin) return;
// API 永远只走网络,任何账本与账户数据都不写入缓存。
if (url.pathname.startsWith("/api/")) return;

if (PRECACHE_URLS.includes(url.pathname)) {
event.respondWith(
caches
.match(event.request)
.then((cached) => cached ?? fetch(event.request)),
);
return;
}

// 带内容哈希的纯静态前端脚本、样式与字体使用本地缓存优先,保证离线前端壳完整启动。
if (
event.request.mode !== "navigate" &&
(SHELL_ASSETS.includes(url.pathname) ||
HASHED_ASSET_PATH.test(url.pathname))
) {
event.respondWith(
(async () => {
const cache = await caches.open(SHELL_CACHE_NAME);
const cached = await cache.match(url.pathname);
if (cached) return cached;
const response = await fetch(event.request);
if (
response.status === 200 &&
!response.redirected &&
response.type !== "opaqueredirect"
) {
await cache.put(url.pathname, response.clone());
}
return response;
})(),
);
return;
}

if (event.request.mode !== "navigate") return;

const isAuthNavigation =
url.origin === self.location.origin && url.pathname.startsWith("/auth/");
const isAuthNavigation = url.pathname.startsWith("/auth/");
const useConfirmedTimeout =
isAuthNavigation || confirmedNavigateUntil > Date.now();
if (!isAuthNavigation) confirmedNavigateUntil = 0;
Expand All @@ -99,23 +230,38 @@ self.addEventListener("fetch", (event) => {
}, timeoutMs);
});
try {
// 网络导航仍需经过外部认证;预加载与 Worker 启动并行,避免冷 Worker 先阻塞网络。
const network = (async () =>
(await event.preloadResponse) ??
(await fetch(event.request, { signal: controller.signal })))();
const network = fetch(event.request, { signal: controller.signal });
network.catch(() => undefined);
const response = await Promise.race([network, timeout]);
if (isAuthNavigation) return response;
if (
!isAuthNavigation &&
(response.status === 502 ||
response.status === 503 ||
response.status === 504)
response.status === 502 ||
response.status === 503 ||
response.status === 504
) {
return (await caches.match(OFFLINE_URL)) ?? response;
return (await matchNavigationFallback()) ?? response;
}
if (isSessionRedirect(response)) {
event.waitUntil?.(
caches
.open(SHELL_CACHE_NAME)
.then((cache) => cache.delete(SHELL_INDEX_URL)),
);
return response;
}
if (isHtmlShellResponse(response)) {
const copy = response.clone();
event.waitUntil?.(
caches
.open(SHELL_CACHE_NAME)
.then((cache) => cache.put(SHELL_INDEX_URL, copy))
.then(() => warmShellAssets()),
);
}
return response;
} catch {
return caches.match(OFFLINE_URL);
if (isAuthNavigation) return caches.match(OFFLINE_URL);
return matchNavigationFallback();
} finally {
clearTimeout(timer);
}
Expand Down
50 changes: 48 additions & 2 deletions scripts/compress-web-assets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createHash } from "node:crypto";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, relative } from "node:path";
import { brotliCompressSync, constants, gzipSync } from "node:zlib";

const webRoot = join(import.meta.dir, "..", "dist", "web");
const compressible = /\.(?:css|html|js|json|svg|webmanifest)$/;

const shellAsset = /\.(?:css|js|woff2)$/;
async function filesIn(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
Expand All @@ -16,6 +17,51 @@ async function filesIn(directory: string): Promise<string[]> {
return files;
}

async function injectServiceWorkerShellManifest() {
const assetsDir = join(webRoot, "assets");
const assetFiles = (await filesInAll(assetsDir))
.filter((path) => shellAsset.test(path))
.map((path) => `/${relative(webRoot, path).replaceAll("\\", "/")}`)
.sort();
const indexHtml = await readFile(join(webRoot, "index.html"), "utf8");
const revision = createHash("sha256")
.update(indexHtml)
.update("\n")
.update(assetFiles.join("\n"))
.digest("hex")
.slice(0, 12);
const swPath = join(webRoot, "sw.js");
const swSource = await readFile(swPath, "utf8");
const cacheReplaced = swSource.replace(
'const SHELL_CACHE_NAME = "meterleaf-shell-dev";',
`const SHELL_CACHE_NAME = "meterleaf-shell-${revision}";`,
);
if (cacheReplaced === swSource) {
throw new Error("Failed to inject SHELL_CACHE_NAME into dist/web/sw.js");
}
const manifestReplaced = cacheReplaced.replace(
"const SHELL_ASSETS = [];",
`const SHELL_ASSETS = ${JSON.stringify(assetFiles)};`,
);
if (manifestReplaced === cacheReplaced) {
throw new Error("Failed to inject SHELL_ASSETS into dist/web/sw.js");
}
await writeFile(swPath, manifestReplaced);
}

async function filesInAll(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...(await filesInAll(path)));
else if (entry.isFile()) files.push(path);
}
return files;
}

await injectServiceWorkerShellManifest();

const files = await filesIn(webRoot);
let generated = 0;
for (const path of files) {
Expand Down
Loading
Loading
0