mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(web): add PWA installation and offline app shell
This commit is contained in:
1 parent
95cb3134db
commit
e3cabf4b1f
16 files changed
+296
-5
No files matched your search
@@ -11,6 +11,7 @@ The project README introduces reader. Use the guides below for installation, eve
|
||||
| --- | --- | --- |
|
||||
| 安装、首次登录、添加书籍、更新 / Installation and first steps | [入门指南](getting-started.zh-CN.md) | [Getting started](getting-started.en.md) |
|
||||
| 书架、阅读、朗读、来源和离线 / Shelves, reading, TTS, sources, offline | [使用手册](user-guide.zh-CN.md) | Chinese only |
|
||||
| 安装到桌面、离线启动和版本更新 / PWA installation, offline startup and updates | [PWA 指南](pwa.zh-CN.md) | Chinese only |
|
||||
| 环境变量、目录权限、HTTPS、排错 / Settings, permissions, HTTPS, troubleshooting | [部署配置](configuration.zh-CN.md) | [Configuration](configuration.en.md) |
|
||||
| 本地开发、验证、镜像与 Android 构建 / Development, checks, Docker and Android builds | [开发指南](development.zh-CN.md) | [Development](development.en.md) |
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# PWA 安装与离线使用
|
||||
|
||||
生产构建(`cd web && npm run build`)自动生成应用清单、桌面图标和 Service Worker,随现有 Web 服务一起部署,无需额外服务。
|
||||
|
||||
- 使用 HTTPS 访问;本机 `localhost` / `127.0.0.1` 可使用 HTTP。通过局域网 IP 的普通 HTTP 访问仍能在线阅读,但浏览器不会启用 PWA 离线缓存。
|
||||
- Chrome / Edge:通过地址栏安装图标或浏览器菜单中的安装应用功能安装。
|
||||
- iPhone / iPad:在 Safari 中选择“分享 → 添加到主屏幕”。
|
||||
- 首次联网打开后,等待页面资源缓存完成,再离线使用。书籍正文需要在书架中提前下载;页面缓存不会自动下载整个书库。
|
||||
- 离线时保留原有登录状态即可打开已缓存书架和已下载书籍。首次登录、未下载内容和需要服务器的功能仍需联网。
|
||||
- 新版本会在访问时后台下载,关闭该站点的所有页面及独立应用窗口,再次打开后生效。更新不会强制刷新正在阅读的页面。
|
||||
- Service Worker 仅缓存应用静态资源,不缓存 API、令牌或书籍响应;书籍和阅读数据继续使用原有按账号隔离的存储。
|
||||
|
||||
开发服务器不注册 Service Worker;Android 原生 WebView 继续使用本地资源。清理站点数据会同时删除离线页面及本地阅读缓存。
|
||||
|
||||
验证:`cd web && npm run build && node tools/pwa/smoke.mjs`。测试需要 Playwright 的 Chromium,可通过 `npx playwright install chromium` 安装。
|
||||
@@ -81,11 +81,9 @@ export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
return;
|
||||
}
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
// Vite writes content-hashed file names, so a JS/CSS asset is immutable:
|
||||
// its bytes cannot change without the name changing too. `index.html` is
|
||||
// the one file whose name never changes, so it must never be cached or a
|
||||
// deployment would never pick up a new bundle.
|
||||
const immutable = extname(candidate) !== '.html';
|
||||
// The Android-compatible build uses fixed names (client.js, sw.js, etc.).
|
||||
// Only explicitly fingerprinted assets can safely be cached forever.
|
||||
const immutable = raw.startsWith('assets/') && /-[a-zA-Z0-9_-]{6,}\.[^.]+$/.test(raw);
|
||||
return sendFile(
|
||||
candidate,
|
||||
reply,
|
||||
|
||||
@@ -23,6 +23,9 @@ before(async () => {
|
||||
await writeFile(join(root, 'index.html'), '<!doctype html><title>reader</title>');
|
||||
await mkdir(join(root, 'assets'));
|
||||
await writeFile(join(root, 'assets', 'index-abc123.js'), 'console.log(1)');
|
||||
await writeFile(join(root, 'assets', 'client.js'), 'console.log(2)');
|
||||
await writeFile(join(root, 'sw.js'), 'self.addEventListener("install", () => {})');
|
||||
await writeFile(join(root, 'manifest.webmanifest'), '{"name":"reader"}');
|
||||
// A file one level above the build, to prove traversal is refused.
|
||||
await writeFile(join(root, '..', `secret-${process.pid}.txt`), 'do not serve me');
|
||||
|
||||
@@ -57,6 +60,18 @@ describe('static client hosting', () => {
|
||||
assert.match(res.headers['cache-control'] as string, /immutable/);
|
||||
});
|
||||
|
||||
test('fixed-name bundles and PWA metadata must revalidate after deployment', async () => {
|
||||
for (const url of ['/assets/client.js', '/sw.js', '/manifest.webmanifest']) {
|
||||
const res = await app.inject({ method: 'GET', url });
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['cache-control'], 'no-cache');
|
||||
}
|
||||
const worker = await app.inject({ method: 'GET', url: '/sw.js' });
|
||||
assert.match(worker.headers['content-type'] as string, /javascript/);
|
||||
const manifest = await app.inject({ method: 'GET', url: '/manifest.webmanifest' });
|
||||
assert.match(manifest.headers['content-type'] as string, /application\/manifest\+json/);
|
||||
});
|
||||
|
||||
test('an asset is served with its real bytes, not an empty second send', async () => {
|
||||
// This was a 200 with `content-length: 0` for every asset. The cause was a
|
||||
// `reply.send(stream)` followed by a bare `return` in an async handler:
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
-->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#fbfaf7" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" href="./icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="reader" />
|
||||
<title>reader</title>
|
||||
<style>
|
||||
/* First paint only: prevents a white flash before the bundle applies the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 881 B |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "./",
|
||||
"name": "reader 阅读器",
|
||||
"short_name": "reader",
|
||||
"description": "自托管阅读器,支持已下载书籍离线阅读",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#fbfaf7",
|
||||
"theme_color": "#fbfaf7",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Native shells and development builds keep their existing loading lifecycle. */
|
||||
export function registerPwa(): void {
|
||||
if (!import.meta.env.PROD || window.ReaderAndroid || !window.isSecureContext
|
||||
|| !['http:', 'https:'].includes(location.protocol) || !('serviceWorker' in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
const base = new URL(import.meta.env.BASE_URL, document.baseURI);
|
||||
void navigator.serviceWorker.register(new URL('sw.js', base), {
|
||||
scope: base.pathname,
|
||||
updateViaCache: 'none',
|
||||
}).catch((error: unknown) => {
|
||||
// PWA availability must not prevent ordinary online reading.
|
||||
console.warn('离线应用缓存注册失败', error);
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'complete') register();
|
||||
else window.addEventListener('load', register, { once: true });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import './styles/reader.css';
|
||||
import { App } from './app.ts';
|
||||
import { registerPwa } from './core/pwa.ts';
|
||||
|
||||
/**
|
||||
* Entry point.
|
||||
@@ -31,6 +32,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
window.readerApp = app;
|
||||
registerPwa();
|
||||
window.__readerFlush = () => {
|
||||
void app.flush();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { registerPwa } from '../src/core/pwa.ts';
|
||||
|
||||
const register = vi.fn().mockResolvedValue({});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('PROD', true);
|
||||
vi.stubEnv('BASE_URL', './');
|
||||
vi.stubGlobal('isSecureContext', true);
|
||||
vi.stubGlobal('navigator', { serviceWorker: { register } });
|
||||
vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
register.mockClear();
|
||||
});
|
||||
|
||||
it('registers the worker relative to the deployed document and bypasses the HTTP cache', () => {
|
||||
registerPwa();
|
||||
const base = new URL('./', document.baseURI);
|
||||
expect(register).toHaveBeenCalledWith(new URL('sw.js', base), {
|
||||
scope: base.pathname, updateViaCache: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not register in development', () => {
|
||||
vi.stubEnv('PROD', false);
|
||||
registerPwa();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not register in the Android shell', () => {
|
||||
vi.stubGlobal('ReaderAndroid', {});
|
||||
registerPwa();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps ordinary HTTP and unsupported browsers usable', () => {
|
||||
vi.stubGlobal('isSecureContext', false);
|
||||
registerPwa();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
vi.stubGlobal('isSecureContext', true);
|
||||
vi.stubGlobal('navigator', {});
|
||||
expect(registerPwa).not.toThrow();
|
||||
});
|
||||
|
||||
it('reports failed registration without an unhandled rejection', async () => {
|
||||
const error = new Error('storage unavailable');
|
||||
register.mockRejectedValueOnce(error);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
registerPwa();
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith('离线应用缓存注册失败', error));
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
/** Include the actual emitted HTML, CSS, fonts and public assets in one version. */
|
||||
export function pwa() {
|
||||
return {
|
||||
name: 'reader-pwa',
|
||||
apply: 'build',
|
||||
async writeBundle(options) {
|
||||
const directory = resolve(options.dir ?? 'dist');
|
||||
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/'))
|
||||
.filter((name) => name !== 'sw.js' && !name.endsWith('.map'))
|
||||
.sort();
|
||||
const template = await readFile(new URL('./sw.js', import.meta.url), 'utf8');
|
||||
const digest = createHash('sha256').update(template);
|
||||
for (const file of files) digest.update(file).update(await readFile(join(directory, file)));
|
||||
const worker = template.replace('__VERSION__', digest.digest('hex').slice(0, 20))
|
||||
.replace('__PRECACHE__', JSON.stringify(files));
|
||||
await writeFile(join(directory, 'sw.js'), worker);
|
||||
},
|
||||
} satisfies Plugin;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = new URL('../../dist/', import.meta.url);
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
||||
'.png': 'image/png', '.webmanifest': 'application/manifest+json', '.ttf': 'font/ttf' };
|
||||
let updated = false;
|
||||
const server = createServer(async (req, res) => {
|
||||
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||
const path = pathname.replace(/^\/nested\//, '/').replace(/^\//, '') || 'index.html';
|
||||
if (path.startsWith('api/')) {
|
||||
res.writeHead(401, { 'content-type': 'application/json' });
|
||||
res.end('{"error":{"code":"UNAUTHORIZED","message":"sign in"}}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let bytes = await readFile(new URL(path, dist));
|
||||
if (path === 'sw.js' && updated) bytes = Buffer.from(bytes.toString().replace(/[a-f0-9]{20}/, 'updated'));
|
||||
res.writeHead(200, { 'content-type': mime[extname(path)] ?? 'application/octet-stream', 'cache-control': 'no-cache' });
|
||||
res.end(bytes);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
for (const base of ['/', '/nested/']) {
|
||||
updated = false;
|
||||
const context = await browser.newContext();
|
||||
let page = await context.newPage();
|
||||
await page.goto(`${origin}${base}`);
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise((resolve) =>
|
||||
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('#app')?.children.length > 0);
|
||||
const manifest = await page.evaluate(async () => {
|
||||
const url = document.querySelector('link[rel="manifest"]').href;
|
||||
return (await fetch(url)).json();
|
||||
});
|
||||
assert.equal(manifest.display, 'standalone');
|
||||
for (const icon of manifest.icons) {
|
||||
const dimensions = await page.evaluate(async (src) => {
|
||||
const img = new Image(); img.src = src; await img.decode();
|
||||
return `${img.naturalWidth}x${img.naturalHeight}`;
|
||||
}, icon.src);
|
||||
assert.equal(dimensions, icon.sizes);
|
||||
}
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const installability = await cdp.send('Page.getInstallabilityErrors');
|
||||
assert.deepEqual(installability.installabilityErrors, []);
|
||||
await page.evaluate(() => fetch('./api/private?token=test'));
|
||||
const cached = await page.evaluate(async () => {
|
||||
const names = await caches.keys();
|
||||
return (await Promise.all(names.map(async (name) =>
|
||||
(await (await caches.open(name)).keys()).map((req) => req.url)))).flat();
|
||||
});
|
||||
assert(cached.some((url) => url.endsWith('/assets/client.js')));
|
||||
assert(!cached.some((url) => url.includes('/api/')));
|
||||
await context.setOffline(true);
|
||||
await page.close();
|
||||
page = await context.newPage();
|
||||
await page.goto(`${origin}${base}#/shelf`);
|
||||
await page.waitForFunction(() => document.querySelector('#app')?.children.length > 0);
|
||||
assert(!await page.locator('#app').textContent().then((text) => text.includes('客户端启动失败')));
|
||||
assert.equal(await page.evaluate(() => fetch('./api/private').then(() => true, () => false)), false);
|
||||
await context.setOffline(false);
|
||||
updated = true;
|
||||
await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
await registration.update();
|
||||
});
|
||||
await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration()).waiting);
|
||||
// Existing readers retain their version until every controlled page closes.
|
||||
assert.equal(await page.evaluate(async () => (await caches.keys()).length), 2);
|
||||
await page.close();
|
||||
page = await context.newPage();
|
||||
await page.goto(`${origin}${base}`);
|
||||
await page.waitForFunction(async () => {
|
||||
const keys = await caches.keys();
|
||||
return keys.length === 1 && keys[0].endsWith(':updated');
|
||||
});
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => document.querySelector('#app')?.children.length > 0);
|
||||
await context.close();
|
||||
console.log(`PASS ${base}: manifest, icons, installability, offline restart, API isolation, waiting update and cache cleanup`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Build placeholders are replaced after all assets have been emitted. */
|
||||
const scope = self.registration.scope;
|
||||
const prefix = `reader-shell:${scope}:`;
|
||||
const cacheName = `${prefix}__VERSION__`;
|
||||
const files = __PRECACHE__;
|
||||
const urls = files.map((file) => new URL(file, scope).href);
|
||||
const shell = new URL('index.html', scope).href;
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Reload bypasses HTTP caches for the fixed-name Android-compatible bundle.
|
||||
// Do not skipWaiting: an open reader must keep its current bundle until closed.
|
||||
event.waitUntil(caches.open(cacheName).then((cache) =>
|
||||
cache.addAll(urls.map((url) => new Request(url, { cache: 'reload' })))));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key.startsWith(prefix) && key !== cacheName) await caches.delete(key);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'GET' || url.origin !== new URL(scope).origin) return;
|
||||
// Only the application shell belongs here. Account data and book bodies use
|
||||
// the existing account-scoped stores, never a shared HTTP response cache.
|
||||
const home = new URL(scope).pathname;
|
||||
const isHome = request.mode === 'navigate' && (url.pathname === home || url.pathname === new URL(shell).pathname);
|
||||
const key = isHome ? shell : url.href;
|
||||
if (!isHome && !urls.includes(key)) return;
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(cacheName);
|
||||
return (await cache.match(key)) ?? fetch(request);
|
||||
})());
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { pwa } from './tools/pwa/build.ts';
|
||||
|
||||
// The build output is consumed twice:
|
||||
// - served directly by the server for the browser/H5 build
|
||||
@@ -9,6 +10,7 @@ import { defineConfig } from 'vitest/config';
|
||||
// from file:// cannot fetch sibling chunks over the network, and every extra
|
||||
// request through the file protocol is a chance to meet a platform quirk.
|
||||
export default defineConfig({
|
||||
plugins: [pwa()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
||||
Reference in new issue
Block a user