Skip to content
Open
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

## 开发环境

| 开发者类型 | 需要自行准备 | 说明 |
| --- | --- | --- |
| 普通开发者 | [Vite+](https://viteplus.dev/) 的 `vp` CLI | 进入仓库后执行 `vp install`,其余项目工具由仓库管理 |
| 真实接口验证者 | 浙江大学校园网或代理、CC98 账号 | 账号写入 `.cc98-credentials.local`;普通构建和离线测试不需要 |
| 开发者类型 | 需要自行准备 | 说明 |
| ------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| 普通开发者 | [Vite+](https://viteplus.dev/) 的 `vp` CLI | 进入仓库后执行 `vp install`,其余项目工具由仓库管理 |
| 真实接口验证者 | 浙江大学校园网或代理、CC98 账号 | 账号写入 `.cc98-credentials.local`;普通构建和离线测试不需要 |
| 并行或自动化开发者 | [Worktrunk](https://worktrunk.dev/);按需安装浏览器运行环境 | Worktrunk 用于多 worktree;浏览器环境运行 `vp exec agent-browser install` |
| 部署维护者 | Vercel 项目权限;按需安装 Vercel CLI | 普通开发者不需要;生产域名维护还需要相应 DNS 权限 |
| 部署维护者 | Vercel 项目权限;按需安装 Vercel CLI | 普通开发者不需要;生产域名维护还需要相应 DNS 权限 |

安装依赖并启动开发环境:

Expand Down Expand Up @@ -52,7 +52,7 @@ vp run ready # format + lint + typecheck + test + build

## 测试部署

测试站部署在 Vercel,项目根目录保持为仓库根目录。Vercel 会按 `vercel.json` 构建主站及其传递依赖,并发布 `apps/website/dist`。主站源码、静态资源、API 契约源码、UBB 或相关构建配置发生变化时才会部署;测试、探测数据、生成文档和 README 等非构建输入不会触发主站部署。Vue Router 使用 history 模式,所有未命中静态文件的请求都会回退到 `index.html`。
测试站部署在 Vercel,项目根目录保持为仓库根目录。Vercel 会按 `vercel.json` 构建主站及其传递依赖,并发布 `apps/website/dist`。主站源码、静态资源、API 契约源码、UBB 或相关构建配置发生变化时才会部署;测试、探测数据、生成文档和 README 等非构建输入不会触发主站部署。Vue Router 使用 history 模式,只有业务路由未命中时回退到 `index.html`;缺失静态资源、Service Worker、Manifest 和固定 PWA 文件保留真实错误响应

用户文档使用独立的 Vercel 项目 `cc98-docs`,根目录是 `apps/docs`。只有文档内容、文档站配置、静态资源或相关依赖发生变化时才部署文档站。PR 会生成文档预览,`main` 分支发布到 `https://cc98-docs.vercel.app`。

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

支持 PWA 的浏览器可以把 CC98 安装到桌面或主屏幕。安装入口通常位于地址栏或浏览器菜单中,名称可能是“安装应用”“添加到主屏幕”或“创建快捷方式”。安装后会以独立窗口打开,账号和外观设置仍由当前浏览器管理。

页面提示新版本已经准备好时,点击“刷新”即可切换版本。选择“稍后”不会影响当前页面,下次发现更新时还会再次提示。
页面提示新版本已经准备好时,点击“立即更新”即可切换版本。选择“稍后”不会影响当前页面,下次发现更新时还会再次提示。如果页面显示“页面资源加载失败”,说明目标页面所需资源没有加载成功,可能是网络波动或网站刚刚更新。联网后点击“重新加载”即可重试。重新加载前,请先保存尚未提交的内容

## 认识顶部导航

Expand Down
45 changes: 45 additions & 0 deletions apps/website/build/runtime-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
interface AppAssetResponseContext {
request: Request;
response: Response;
}

function validateAppAssetResponse({ request, response }: AppAssetResponseContext): Response | null {
if (response.status !== 200) return null;

const contentType = response.headers.get("Content-Type")?.toLowerCase() ?? "";
const pathname = new URL(request.url).pathname.toLowerCase();

if (pathname.endsWith(".js")) {
return contentType.includes("javascript") || contentType.includes("ecmascript")
? response
: null;
}
if (pathname.endsWith(".css")) {
return contentType.startsWith("text/css") ? response : null;
}
if (/\.(?:woff2?|ttf)$/.test(pathname)) {
return contentType.startsWith("font/") ||
contentType.startsWith("application/font-") ||
contentType.startsWith("application/x-font-") ||
contentType.startsWith("application/octet-stream")
? response
: null;
}
return null;
}

export function createAppAssetCachePlugin() {
const cacheWillUpdate = async (context: AppAssetResponseContext): Promise<Response | null> =>
validateAppAssetResponse(context);

const cachedResponseWillBeUsed = async (context: {
request: Request;
cachedResponse?: Response;
}): Promise<Response | null> => {
const { request, cachedResponse } = context;
if (!cachedResponse) return null;
return validateAppAssetResponse({ request, response: cachedResponse });
};

return { cacheWillUpdate, cachedResponseWillBeUsed };
}
79 changes: 71 additions & 8 deletions apps/website/src/components/PwaStatusBanner.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,90 @@
<script setup lang="ts">
import { useOnline } from "@vueuse/core";
import { useEventListener, useIntervalFn, useOnline } from "@vueuse/core";
import { useRegisterSW } from "virtual:pwa-register/vue";
import { ref } from "vue";
import { computed, ref, shallowRef } from "vue";
import { createLogger, logErrorOnce } from "../lib/logger";
import {
createServiceWorkerUpdateChecker,
SERVICE_WORKER_UPDATE_INTERVAL,
waitForWaitingServiceWorker,
} from "./pwa-update";
import UiDialog from "./ui/Dialog.vue";

const logger = createLogger("pwa");
const isOnline = useOnline();
const isUpdating = ref(false);
const updateError = ref("");
const preloadFailed = ref(false);
const registration = shallowRef<ServiceWorkerRegistration>();
const checkForUpdate = createServiceWorkerUpdateChecker(() => registration.value);
const { needRefresh, updateServiceWorker } = useRegisterSW({
onRegisteredSW(_swUrl, nextRegistration) {
registration.value = nextRegistration;
},
onRegisterError(error) {
logErrorOnce(logger, error, "Service Worker 注册失败");
},
});

const promptOpen = computed({
get: () => needRefresh.value || preloadFailed.value,
set: (open: boolean) => {
if (open) return;
needRefresh.value = false;
preloadFailed.value = false;
updateError.value = "";
},
});
const promptTitle = computed(() => (preloadFailed.value ? "页面资源加载失败" : "发现新版本"));
const promptDescription = computed(() =>
preloadFailed.value
? "目标页面所需资源未能加载,可能是网络波动或网站刚刚更新。重新加载当前页面后可以重试,请先保存尚未提交的内容。"
: "新版本已经准备好,立即更新会重新加载当前页面。请先保存尚未提交的内容。",
);
const confirmLabel = computed(() => (preloadFailed.value ? "重新加载" : "立即更新"));

function requestUpdateCheck(force = false): void {
if (!navigator.onLine) return;
void checkForUpdate(force).catch((error: unknown) => {
logErrorOnce(logger, error, "Service Worker 更新检查失败");
});
}

useIntervalFn(() => requestUpdateCheck(), SERVICE_WORKER_UPDATE_INTERVAL);
useEventListener(window, "online", () => requestUpdateCheck());
useEventListener(document, "visibilitychange", () => {
if (document.visibilityState === "visible") requestUpdateCheck();
});
useEventListener(window, "vite:preloadError", (event: Event) => {
if (!navigator.onLine) return;
event.preventDefault();
preloadFailed.value = true;
const error = (event as Event & { payload?: unknown }).payload ?? new Error("页面资源加载失败");
logErrorOnce(logger, error, "页面资源加载失败");
requestUpdateCheck(true);
});

async function update(): Promise<void> {
isUpdating.value = true;
updateError.value = "";
try {
await updateServiceWorker();
const currentRegistration = registration.value;
if (!currentRegistration?.waiting) {
await checkForUpdate(true);
}

if (
currentRegistration?.waiting ||
(currentRegistration && (await waitForWaitingServiceWorker(currentRegistration)))
) {
await updateServiceWorker();
} else {
window.location.reload();
}
} catch (error) {
updateError.value = "更新失败,请稍后重试。";
updateError.value = preloadFailed.value
? "重新加载失败,请稍后重试。"
: "更新失败,请稍后重试。";
logErrorOnce(logger, error, "Service Worker 更新失败");
} finally {
isUpdating.value = false;
Expand All @@ -31,11 +94,11 @@ async function update(): Promise<void> {

<template>
<UiDialog
v-model:open="needRefresh"
title="发现新版本"
description="新版本已经准备好,立即更新会重新加载当前页面。请先保存尚未提交的内容。"
v-model:open="promptOpen"
:title="promptTitle"
:description="promptDescription"
cancel-label="稍后"
confirm-label="立即更新"
:confirm-label="confirmLabel"
:pending="isUpdating"
@confirm="update"
>
Expand Down
71 changes: 71 additions & 0 deletions apps/website/src/components/pwa-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export const SERVICE_WORKER_UPDATE_INTERVAL = 60 * 60 * 1000;
const SERVICE_WORKER_UPDATE_THROTTLE = 5 * 60 * 1000;
const SERVICE_WORKER_WAIT_TIMEOUT = 10_000;

interface UpdateCheckerOptions {
now?: () => number;
throttle?: number;
}

export function createServiceWorkerUpdateChecker(
getRegistration: () => ServiceWorkerRegistration | undefined,
options: UpdateCheckerOptions = {},
): (force?: boolean) => Promise<void> {
const now = options.now ?? Date.now;
const throttle = options.throttle ?? SERVICE_WORKER_UPDATE_THROTTLE;
let lastCheckedAt = Number.NEGATIVE_INFINITY;
let activeCheck: Promise<void> | undefined;

return async (force = false): Promise<void> => {
if (activeCheck) return activeCheck;

const registration = getRegistration();
if (!registration) return;

const checkedAt = now();
if (!force && checkedAt - lastCheckedAt < throttle) return;
lastCheckedAt = checkedAt;

const update = registration.update().then(() => undefined);
const tracked = update.finally(() => {
if (activeCheck === tracked) activeCheck = undefined;
});
activeCheck = tracked;
return tracked;
};
}

export async function waitForWaitingServiceWorker(
registration: ServiceWorkerRegistration,
timeout = SERVICE_WORKER_WAIT_TIMEOUT,
): Promise<boolean> {
if (registration.waiting) return true;

const worker = registration.installing;
if (!worker) return false;
if (worker.state === "installed") return registration.waiting != null;
if (worker.state === "activated" || worker.state === "redundant") return false;

return await new Promise<boolean>((resolve) => {
let settled = false;
const timer = setTimeout(() => finish(false), timeout);

function finish(waiting: boolean): void {
if (settled) return;
settled = true;
clearTimeout(timer);
worker?.removeEventListener("statechange", handleStateChange);
resolve(waiting);
}

function handleStateChange(): void {
if (worker?.state === "installed") {
finish(registration.waiting != null);
} else if (worker?.state === "activated" || worker?.state === "redundant") {
finish(false);
}
}

worker.addEventListener("statechange", handleStateChange);
});
}
78 changes: 78 additions & 0 deletions apps/website/tests/pwa-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test, vi } from "vite-plus/test";
import {
createServiceWorkerUpdateChecker,
waitForWaitingServiceWorker,
} from "../src/components/pwa-update.ts";

function registrationWithUpdate(update: () => Promise<unknown>): ServiceWorkerRegistration {
return { update } as unknown as ServiceWorkerRegistration;
}

describe("Service Worker 更新检查", () => {
test("合并并发检查并限制短时间内的重复请求", async () => {
let now = 1_000;
let finishUpdate: (() => void) | undefined;
let firstUpdate = true;
const update = vi.fn(() => {
if (!firstUpdate) return Promise.resolve();
firstUpdate = false;
return new Promise<void>((resolve) => {
finishUpdate = resolve;
});
});
const registration = registrationWithUpdate(update);
const check = createServiceWorkerUpdateChecker(() => registration, {
now: () => now,
throttle: 500,
});

const first = check();
const concurrent = check();
expect(update).toHaveBeenCalledTimes(1);
finishUpdate?.();
await Promise.all([first, concurrent]);

now = 1_200;
await check();
expect(update).toHaveBeenCalledTimes(1);

await check(true);
expect(update).toHaveBeenCalledTimes(2);

now = 1_700;
await check();
expect(update).toHaveBeenCalledTimes(3);
});

test("注册尚未完成时跳过检查", async () => {
const check = createServiceWorkerUpdateChecker(() => undefined);
await expect(check()).resolves.toBeUndefined();
});
});

describe("等待新 Service Worker", () => {
test("安装完成并进入 waiting 后返回 true", async () => {
const worker = new EventTarget() as EventTarget & { state: ServiceWorkerState };
worker.state = "installing";
const registration = {
installing: worker,
waiting: null,
} as unknown as ServiceWorkerRegistration;

const waiting = waitForWaitingServiceWorker(registration, 100);
Object.assign(registration, { waiting: worker });
worker.state = "installed";
worker.dispatchEvent(new Event("statechange"));

await expect(waiting).resolves.toBe(true);
});

test("没有正在安装的 Worker 时不等待", async () => {
const registration = {
installing: null,
waiting: null,
} as unknown as ServiceWorkerRegistration;

await expect(waitForWaitingServiceWorker(registration, 100)).resolves.toBe(false);
});
});
Loading