@microbt/pwa 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -2
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/protocol.d.ts +2 -0
- package/dist/esm/protocol.js +11 -0
- package/dist/esm/recovery.d.ts +3 -0
- package/dist/esm/recovery.js +11 -0
- package/dist/esm/shared/chunk-diagnostic-outbox.d.ts +13 -0
- package/dist/esm/shared/chunk-diagnostic-outbox.js +137 -0
- package/dist/esm/shared/chunk-diagnostic-payload.d.ts +17 -0
- package/dist/esm/shared/chunk-diagnostic-payload.js +162 -0
- package/dist/esm/shared/chunk-diagnostic-reporter.d.ts +9 -0
- package/dist/esm/shared/chunk-diagnostic-reporter.js +114 -0
- package/dist/esm/shared/chunk-diagnostics.d.ts +26 -0
- package/dist/esm/shared/chunk-diagnostics.js +18 -0
- package/dist/esm/shared/chunk-error-recovery.d.ts +51 -0
- package/dist/esm/shared/chunk-error-recovery.js +225 -0
- package/dist/esm/shared/pwa-registration-telemetry.d.ts +4 -0
- package/dist/esm/shared/pwa-registration-telemetry.js +38 -0
- package/dist/esm/shared/pwa-retry.d.ts +26 -0
- package/dist/esm/shared/pwa-retry.js +133 -0
- package/dist/esm/shared/pwa.d.ts +5 -0
- package/dist/esm/shared/pwa.js +231 -0
- package/dist/esm/shared/sw-registration.d.ts +11 -0
- package/dist/esm/shared/sw-registration.js +35 -0
- package/dist/esm/shared/sw-script-url.d.ts +42 -0
- package/dist/esm/shared/sw-script-url.js +49 -0
- package/dist/esm/sw-cache-validation.d.ts +51 -0
- package/dist/esm/sw-cache-validation.js +111 -0
- package/dist/esm/sw-chunk-diagnostics.d.ts +19 -0
- package/dist/esm/sw-chunk-diagnostics.js +124 -0
- package/dist/esm/sw-error-filter.d.ts +58 -0
- package/dist/esm/sw-error-filter.js +87 -0
- package/dist/esm/sw-polyfills.d.ts +1 -0
- package/dist/esm/sw-polyfills.js +14 -0
- package/dist/esm/sw-request-filter.d.ts +13 -0
- package/dist/esm/sw-request-filter.js +7 -0
- package/dist/esm/sw.d.ts +4 -0
- package/dist/esm/sw.js +194 -0
- package/package.json +90 -4
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// src/shared/pwa.ts
|
|
2
|
+
import { arms } from "@microbt/environment";
|
|
3
|
+
import {
|
|
4
|
+
countRegistrationFailure,
|
|
5
|
+
createRegistrationError,
|
|
6
|
+
registrationErrorMessage
|
|
7
|
+
} from "./pwa-registration-telemetry";
|
|
8
|
+
import { SwRegistrationRetry } from "./pwa-retry";
|
|
9
|
+
import { removeInvalidRegistrations, resolveSwRegisterUrl } from "./sw-registration";
|
|
10
|
+
var PwaRegistrationScheduler = class {
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.scheduleStarted = false;
|
|
14
|
+
this.firstPaintQueued = false;
|
|
15
|
+
this.registrationCompleted = false;
|
|
16
|
+
this.registrationInFlight = null;
|
|
17
|
+
this.idleHandle = null;
|
|
18
|
+
this.timeoutHandle = null;
|
|
19
|
+
this.visibilityListenerAttached = false;
|
|
20
|
+
this.onlineListenerAttached = false;
|
|
21
|
+
this.retry = new SwRegistrationRetry(
|
|
22
|
+
() => this.registrationCompleted,
|
|
23
|
+
() => {
|
|
24
|
+
void this.registerServiceWorker();
|
|
25
|
+
},
|
|
26
|
+
(message, ...args) => this.options.log(message, ...args)
|
|
27
|
+
);
|
|
28
|
+
this.clearPendingRegistrationTask = () => {
|
|
29
|
+
const idleWindow = this.getIdleWindow();
|
|
30
|
+
if (this.idleHandle !== null && idleWindow.cancelIdleCallback) {
|
|
31
|
+
idleWindow.cancelIdleCallback(this.idleHandle);
|
|
32
|
+
this.idleHandle = null;
|
|
33
|
+
}
|
|
34
|
+
if (this.timeoutHandle !== null) {
|
|
35
|
+
window.clearTimeout(this.timeoutHandle);
|
|
36
|
+
this.timeoutHandle = null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
this.detachReadinessListeners = () => {
|
|
40
|
+
if (this.visibilityListenerAttached) {
|
|
41
|
+
document.removeEventListener("visibilitychange", this.handleReadinessChange);
|
|
42
|
+
this.visibilityListenerAttached = false;
|
|
43
|
+
}
|
|
44
|
+
if (this.onlineListenerAttached) {
|
|
45
|
+
window.removeEventListener("online", this.handleReadinessChange);
|
|
46
|
+
this.onlineListenerAttached = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
this.attachReadinessListeners = () => {
|
|
50
|
+
if (!this.visibilityListenerAttached) {
|
|
51
|
+
document.addEventListener("visibilitychange", this.handleReadinessChange);
|
|
52
|
+
this.visibilityListenerAttached = true;
|
|
53
|
+
}
|
|
54
|
+
if (!this.onlineListenerAttached) {
|
|
55
|
+
window.addEventListener("online", this.handleReadinessChange);
|
|
56
|
+
this.onlineListenerAttached = true;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
this.queueRegistrationWhenIdle = () => {
|
|
60
|
+
if (this.registrationCompleted || this.registrationInFlight) {
|
|
61
|
+
this.detachReadinessListeners();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (document.visibilityState !== "visible" || !navigator.onLine) {
|
|
65
|
+
this.waitUntilPageReady();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (this.idleHandle !== null || this.timeoutHandle !== null) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const runRegistration = () => {
|
|
72
|
+
this.idleHandle = null;
|
|
73
|
+
this.timeoutHandle = null;
|
|
74
|
+
if (document.visibilityState !== "visible" || !navigator.onLine) {
|
|
75
|
+
this.waitUntilPageReady();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
void this.registerServiceWorker();
|
|
79
|
+
};
|
|
80
|
+
const idleWindow = this.getIdleWindow();
|
|
81
|
+
if (idleWindow.requestIdleCallback) {
|
|
82
|
+
this.idleHandle = idleWindow.requestIdleCallback(runRegistration, {
|
|
83
|
+
timeout: 3e3
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.timeoutHandle = window.setTimeout(runRegistration, 3e3);
|
|
88
|
+
};
|
|
89
|
+
this.scheduleAfterFirstPaint = () => {
|
|
90
|
+
if (this.firstPaintQueued || this.registrationCompleted || this.registrationInFlight) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.firstPaintQueued = true;
|
|
94
|
+
window.requestAnimationFrame(() => {
|
|
95
|
+
window.requestAnimationFrame(() => {
|
|
96
|
+
this.firstPaintQueued = false;
|
|
97
|
+
this.queueRegistrationWhenIdle();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
this.waitUntilPageReady = () => {
|
|
102
|
+
this.clearPendingRegistrationTask();
|
|
103
|
+
if (document.visibilityState === "visible" && navigator.onLine) {
|
|
104
|
+
this.detachReadinessListeners();
|
|
105
|
+
this.scheduleAfterFirstPaint();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.attachReadinessListeners();
|
|
109
|
+
};
|
|
110
|
+
this.handleReadinessChange = () => {
|
|
111
|
+
if (this.registrationCompleted || this.registrationInFlight) {
|
|
112
|
+
this.detachReadinessListeners();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.waitUntilPageReady();
|
|
116
|
+
};
|
|
117
|
+
this.registerServiceWorker = async () => {
|
|
118
|
+
if (this.registrationCompleted) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.registrationInFlight) {
|
|
122
|
+
await this.registrationInFlight;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.registrationInFlight = (async () => {
|
|
126
|
+
var _a;
|
|
127
|
+
let swRegisterUrl = null;
|
|
128
|
+
let stage = "load-workbox";
|
|
129
|
+
try {
|
|
130
|
+
const { Workbox } = await import("workbox-window");
|
|
131
|
+
stage = "resolve-url";
|
|
132
|
+
swRegisterUrl = await resolveSwRegisterUrl({
|
|
133
|
+
version: this.options.version
|
|
134
|
+
});
|
|
135
|
+
stage = "register-sw";
|
|
136
|
+
const wb = new Workbox(swRegisterUrl);
|
|
137
|
+
const startTime = +/* @__PURE__ */ new Date();
|
|
138
|
+
wb.addEventListener("installed", () => {
|
|
139
|
+
const endTime = +/* @__PURE__ */ new Date();
|
|
140
|
+
this.options.log(
|
|
141
|
+
`Service Worker installed:${endTime}, time taken:${endTime - startTime}`
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
wb.addEventListener("activated", () => {
|
|
145
|
+
this.options.log(
|
|
146
|
+
`Service Worker activated:${+/* @__PURE__ */ new Date()}, online status: ${navigator.onLine}`
|
|
147
|
+
);
|
|
148
|
+
if (!navigator.onLine) {
|
|
149
|
+
arms.sum("PWA-ACTIVATED-OFFLINE");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
wb.addEventListener("controlling", () => {
|
|
153
|
+
this.options.log(`Service Worker has taken control of the current client:${+/* @__PURE__ */ new Date()}`);
|
|
154
|
+
});
|
|
155
|
+
wb.addEventListener("waiting", () => {
|
|
156
|
+
this.options.log(`Service Worker is waiting:${+/* @__PURE__ */ new Date()}`);
|
|
157
|
+
});
|
|
158
|
+
const res = await wb.register();
|
|
159
|
+
this.options.log(
|
|
160
|
+
`Service Worker registration successful:${startTime}, state: ${(_a = res == null ? void 0 : res.active) == null ? void 0 : _a.state}, installing: ${res == null ? void 0 : res.installing}, host:${location.host}`
|
|
161
|
+
);
|
|
162
|
+
arms.sum("PWA-REGISTER-SUCCESS");
|
|
163
|
+
this.retry.onSuccess();
|
|
164
|
+
this.registrationCompleted = true;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (this.shouldIgnoreError(e)) {
|
|
167
|
+
this.retry.stop();
|
|
168
|
+
this.options.log(
|
|
169
|
+
`Service Worker registration restricted (report ignored): ${registrationErrorMessage(
|
|
170
|
+
e
|
|
171
|
+
)}`
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const error = createRegistrationError(e, stage);
|
|
176
|
+
this.retry.reportErrorOnce(error);
|
|
177
|
+
countRegistrationFailure(stage);
|
|
178
|
+
this.retry.scheduleRetry();
|
|
179
|
+
this.options.log(`Service Worker registration failed: ${error.message}`, error);
|
|
180
|
+
} finally {
|
|
181
|
+
this.registrationInFlight = null;
|
|
182
|
+
this.clearPendingRegistrationTask();
|
|
183
|
+
this.detachReadinessListeners();
|
|
184
|
+
}
|
|
185
|
+
if (this.registrationCompleted && swRegisterUrl !== null) {
|
|
186
|
+
try {
|
|
187
|
+
await removeInvalidRegistrations(
|
|
188
|
+
swRegisterUrl,
|
|
189
|
+
(message, ...args) => this.options.log(message, ...args)
|
|
190
|
+
);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
this.options.log(`清理历史 SW 注册失败(不影响本次注册): ${e.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
await this.registrationInFlight;
|
|
197
|
+
};
|
|
198
|
+
this.schedule = () => {
|
|
199
|
+
if (this.scheduleStarted) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (typeof window === "undefined" || typeof document === "undefined" || // WebView / opaque origin 下 serviceWorker 属性存在但值为 null,
|
|
203
|
+
// 必须用真值检查而非 'serviceWorker' in navigator
|
|
204
|
+
!navigator.serviceWorker) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.scheduleStarted = true;
|
|
208
|
+
this.waitUntilPageReady();
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
shouldIgnoreError(error) {
|
|
212
|
+
const errorMessages = [
|
|
213
|
+
"Job rejected for non app-bound domain"
|
|
214
|
+
// iOS PWA注册限制错误
|
|
215
|
+
// 'The operation is insecure', // 某些浏览器安全限制错误
|
|
216
|
+
// 'Failed to register a ServiceWorker', // 通用注册失败错误
|
|
217
|
+
];
|
|
218
|
+
return errorMessages.some((msg) => registrationErrorMessage(error).includes(msg));
|
|
219
|
+
}
|
|
220
|
+
getIdleWindow() {
|
|
221
|
+
return window;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
var pwaRegistrationScheduler = null;
|
|
225
|
+
var schedulePwaRegistration = (options) => {
|
|
226
|
+
pwaRegistrationScheduler ??= new PwaRegistrationScheduler(options);
|
|
227
|
+
pwaRegistrationScheduler.schedule();
|
|
228
|
+
};
|
|
229
|
+
export {
|
|
230
|
+
schedulePwaRegistration
|
|
231
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PwaLog } from './pwa';
|
|
2
|
+
/** 解析本次应注册的 SW 地址(在线/离线/同构建复用,见 sw-script-url.ts) */
|
|
3
|
+
export declare function resolveSwRegisterUrl(options: {
|
|
4
|
+
version: string;
|
|
5
|
+
}): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* 历史注册清理:注销与本次注册地址不同(规范化比较,消除绝对/相对差异)
|
|
8
|
+
* 的本站 /sw.js 注册。必须在 register 成功之后调用——若 register 失败
|
|
9
|
+
* 直接跳过,保证任何时候不把正在使用的注册注销掉。
|
|
10
|
+
*/
|
|
11
|
+
export declare function removeInvalidRegistrations(keepScriptUrl: string, log: PwaLog): Promise<void>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/shared/sw-registration.ts
|
|
2
|
+
import { isSameOriginSwScriptUrl, normalizeScriptUrl, resolveSwScriptUrl } from "./sw-script-url";
|
|
3
|
+
function getRegistrationScriptUrl(registration) {
|
|
4
|
+
var _a, _b, _c;
|
|
5
|
+
return ((_a = registration.active) == null ? void 0 : _a.scriptURL) ?? ((_b = registration.waiting) == null ? void 0 : _b.scriptURL) ?? ((_c = registration.installing) == null ? void 0 : _c.scriptURL) ?? "";
|
|
6
|
+
}
|
|
7
|
+
async function resolveSwRegisterUrl(options) {
|
|
8
|
+
const publicPath = window.publicPath;
|
|
9
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
10
|
+
const existingScriptUrls = registrations.map((registration) => getRegistrationScriptUrl(registration)).filter((scriptUrl) => scriptUrl !== "");
|
|
11
|
+
return resolveSwScriptUrl({
|
|
12
|
+
publicPath: typeof publicPath === "string" ? publicPath : void 0,
|
|
13
|
+
existingScriptUrls,
|
|
14
|
+
version: options.version,
|
|
15
|
+
pageOrigin: window.location.origin
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function removeInvalidRegistrations(keepScriptUrl, log) {
|
|
19
|
+
const pageOrigin = window.location.origin;
|
|
20
|
+
const keepUrl = normalizeScriptUrl(keepScriptUrl, pageOrigin);
|
|
21
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
22
|
+
const staleRegistrations = registrations.filter((registration) => {
|
|
23
|
+
const scriptUrl = getRegistrationScriptUrl(registration);
|
|
24
|
+
return scriptUrl !== "" && normalizeScriptUrl(scriptUrl, pageOrigin) !== keepUrl && isSameOriginSwScriptUrl(scriptUrl, pageOrigin);
|
|
25
|
+
});
|
|
26
|
+
if (staleRegistrations.length === 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await Promise.all(staleRegistrations.map((registration) => registration.unregister()));
|
|
30
|
+
log(`已注销 ${staleRegistrations.length} 个非当前地址的 SW 注册`);
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
removeInvalidRegistrations,
|
|
34
|
+
resolveSwRegisterUrl
|
|
35
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SW 注册地址(scriptURL)的构造与解析。
|
|
3
|
+
*
|
|
4
|
+
* 注册地址形如 `/sw.js?baseUrl=<CDN 版本目录>&v=<构建版本>`:
|
|
5
|
+
* - baseUrl:服务端转发层的版本路由参数(nginx location = /sw.js 按
|
|
6
|
+
* $arg_baseUrl proxy_pass 到 CDN 版本目录取 sw.js),在线 HTML 由 render
|
|
7
|
+
* 服务注入 window.publicPath,离线包 HTML 无 publicPath,按设计兜底为
|
|
8
|
+
* 字面量 'undefined',由 Native 按 path 命中本地包内 sw.js。
|
|
9
|
+
* - v:构建版本(RELEASE)。这是离线场景 SW 更新的关键通道——实测
|
|
10
|
+
* Chrome 91 WebView 中,register() 携带新 scriptURL 的安装 fetch 会被
|
|
11
|
+
* ServiceWorkerClient 拦截并命中 Native 本地包,而 update()/自然软更新
|
|
12
|
+
* 的 fetch 不经过任何 Java 拦截、直落 nginx(baseUrl=undefined 时 500)。
|
|
13
|
+
* 因此 scriptURL 必须随构建版本变化:新包的页面以新 URL 注册,经安装
|
|
14
|
+
* 通道取得新 SW 并接管;同构建重复打开时注册同一 URL,不触发重装。
|
|
15
|
+
*/
|
|
16
|
+
/** 按转发层认识的格式拼 SW 注册地址(含构建版本参数) */
|
|
17
|
+
export declare function buildSwRegisterUrl(baseUrl: string, version: string): string;
|
|
18
|
+
/** 解析注册地址中的构建版本参数;无 v 参数(历史注册)返回 null */
|
|
19
|
+
export declare function getSwScriptVersion(scriptUrl: string, pageOrigin: string): string | null;
|
|
20
|
+
/** 判定注册地址是否为本站 /sw.js(用于历史注册清理的范围判断) */
|
|
21
|
+
export declare function isSameOriginSwScriptUrl(scriptUrl: string, pageOrigin: string): boolean;
|
|
22
|
+
/** 用同一 origin 规范化注册地址,消除绝对/相对形式差异后比较 */
|
|
23
|
+
export declare function normalizeScriptUrl(url: string, pageOrigin: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* 解析本次应注册的 SW 地址(纯函数,便于单测):
|
|
26
|
+
* 1. 在线入口(publicPath 为协议相对或绝对 http(s) URL):注册
|
|
27
|
+
* baseUrl=publicPath 的当前构建地址,保持 nginx 版本转发链路。
|
|
28
|
+
* 2. 离线入口:若已有同构建版本(v 一致)的本站 /sw.js 注册——无论其
|
|
29
|
+
* baseUrl 是 CDN 基址还是 undefined 兜底——原样复用其 scriptURL,
|
|
30
|
+
* 在线/离线交替打开时不重装、不产生 registration 抖动。
|
|
31
|
+
* 3. 其余情况(仅旧构建/无 v 历史注册,或无注册):注册
|
|
32
|
+
* baseUrl=undefined 兜底地址 + 当前构建 v。scriptURL 与历史注册不同,
|
|
33
|
+
* 触发安装 fetch(被 ServiceWorkerClient 拦截、命中 Native 当前包的
|
|
34
|
+
* sw.js)完成离线 SW 更新;不回用旧构建的 CDN 基址,避免安装 fetch
|
|
35
|
+
* 未被拦截时从 CDN 取回旧版本脚本。
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveSwScriptUrl({ publicPath, existingScriptUrls, version, pageOrigin, }: {
|
|
38
|
+
publicPath: string | undefined;
|
|
39
|
+
existingScriptUrls: string[];
|
|
40
|
+
version: string;
|
|
41
|
+
pageOrigin: string;
|
|
42
|
+
}): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/shared/sw-script-url.ts
|
|
2
|
+
function buildSwRegisterUrl(baseUrl, version) {
|
|
3
|
+
return `/sw.js?baseUrl=${encodeURIComponent(baseUrl)}&v=${encodeURIComponent(version)}`;
|
|
4
|
+
}
|
|
5
|
+
function getSwScriptVersion(scriptUrl, pageOrigin) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(scriptUrl, pageOrigin).searchParams.get("v");
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isSameOriginSwScriptUrl(scriptUrl, pageOrigin) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(scriptUrl, pageOrigin);
|
|
15
|
+
return url.origin === pageOrigin && url.pathname === "/sw.js";
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function normalizeScriptUrl(url, pageOrigin) {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url, pageOrigin).toString();
|
|
23
|
+
} catch {
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function resolveSwScriptUrl({
|
|
28
|
+
publicPath,
|
|
29
|
+
existingScriptUrls,
|
|
30
|
+
version,
|
|
31
|
+
pageOrigin
|
|
32
|
+
}) {
|
|
33
|
+
if (typeof publicPath === "string" && (publicPath.startsWith("//") || /^https?:\/\//.test(publicPath))) {
|
|
34
|
+
return buildSwRegisterUrl(publicPath, version);
|
|
35
|
+
}
|
|
36
|
+
for (const scriptUrl of existingScriptUrls) {
|
|
37
|
+
if (isSameOriginSwScriptUrl(scriptUrl, pageOrigin) && getSwScriptVersion(scriptUrl, pageOrigin) === version) {
|
|
38
|
+
return scriptUrl;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return buildSwRegisterUrl("undefined", version);
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
buildSwRegisterUrl,
|
|
45
|
+
getSwScriptVersion,
|
|
46
|
+
isSameOriginSwScriptUrl,
|
|
47
|
+
normalizeScriptUrl,
|
|
48
|
+
resolveSwScriptUrl
|
|
49
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceWorker 资源缓存的响应校验。
|
|
3
|
+
*
|
|
4
|
+
* 背景:support 根域源站的 SPA fallback 会对任意路径(含 .js/.css)返回
|
|
5
|
+
* 200 + text/html 骨架页(响应头含 x-content-type-options: nosniff)。
|
|
6
|
+
* 当离线包拦截放行(站点禁用 / 包校验失败 / 版本未激活)时,SW 直接落网,
|
|
7
|
+
* 会把 HTML 响应写入 JS/CSS 缓存;StaleWhileRevalidate 之后先返回坏条目,
|
|
8
|
+
* 浏览器严格 MIME 检查拒绝执行,触发 ChunkLoadError 白屏(坏条目随后台
|
|
9
|
+
* revalidate 逐个自愈,表现为间歇性白屏)。
|
|
10
|
+
* 因此缓存前必须校验响应 MIME 与资源扩展名匹配,并在激活时清理历史坏条目。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* 资源缓存名前缀。sw.ts 两条路由的 cacheName 必须复用这两个常量构造
|
|
14
|
+
* (`${PREFIX}${origin}`),否则 activate 清理会因名字漂移静默失效
|
|
15
|
+
*/
|
|
16
|
+
export declare const JS_CSS_CACHE_NAME_PREFIX = "cache-js-css-";
|
|
17
|
+
export declare const STATIC_ASSETS_CACHE_NAME_PREFIX = "cache-static-assets-";
|
|
18
|
+
interface RequestLike {
|
|
19
|
+
readonly url: string;
|
|
20
|
+
}
|
|
21
|
+
interface ResponseLike {
|
|
22
|
+
readonly headers: {
|
|
23
|
+
get(name: string): string | null;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface CacheLike {
|
|
27
|
+
keys(): Promise<readonly Request[]>;
|
|
28
|
+
match(request: Request): Promise<Response | undefined>;
|
|
29
|
+
delete(request: Request): Promise<boolean>;
|
|
30
|
+
}
|
|
31
|
+
export interface CacheStorageLike {
|
|
32
|
+
keys(): Promise<string[]>;
|
|
33
|
+
open(cacheName: string): Promise<CacheLike>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 判断响应 MIME 是否与请求 URL 扩展名不匹配(例如 .js 请求拿到 text/html)。
|
|
37
|
+
* 未知扩展名 / 无法解析的 URL 不校验,维持原有放行行为。
|
|
38
|
+
*/
|
|
39
|
+
export declare function isMimeMismatch(request: RequestLike, response: ResponseLike): boolean;
|
|
40
|
+
export declare class ValidateResponsePlugin {
|
|
41
|
+
cacheWillUpdate({ request, response }: {
|
|
42
|
+
request: Request;
|
|
43
|
+
response: Response;
|
|
44
|
+
}): Promise<Response | null>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 清理历史坏条目:旧版本 SW 可能把源站 HTML 存进了 JS/CSS/静态资源缓存。
|
|
48
|
+
* 每个坏条目原本要“再白屏一次”才被后台 revalidate 修复,激活时统一清理可立即自愈。
|
|
49
|
+
*/
|
|
50
|
+
export declare function purgeMismatchedCacheEntries(cacheStorage?: CacheStorageLike): Promise<void>;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/sw-cache-validation.ts
|
|
2
|
+
var JS_MIME_TYPES = [
|
|
3
|
+
"application/javascript",
|
|
4
|
+
"text/javascript",
|
|
5
|
+
"application/x-javascript",
|
|
6
|
+
"application/ecmascript",
|
|
7
|
+
"text/ecmascript"
|
|
8
|
+
];
|
|
9
|
+
var STATIC_MIME_RULES = {
|
|
10
|
+
js: JS_MIME_TYPES,
|
|
11
|
+
mjs: JS_MIME_TYPES,
|
|
12
|
+
css: ["text/css"],
|
|
13
|
+
png: ["image/png"],
|
|
14
|
+
jpg: ["image/jpeg"],
|
|
15
|
+
jpeg: ["image/jpeg"],
|
|
16
|
+
gif: ["image/gif"],
|
|
17
|
+
webp: ["image/webp"],
|
|
18
|
+
svg: ["image/svg+xml"],
|
|
19
|
+
ico: ["image/x-icon", "image/vnd.microsoft.icon"],
|
|
20
|
+
// 字体除 IANA 新型(font/*,2017 年注册)外,CDN/OSS 常见旧型
|
|
21
|
+
// application/font-* 与 application/x-font-* 一并放行,避免合法字体
|
|
22
|
+
// 被误判为不匹配而永远进不了缓存
|
|
23
|
+
woff: ["font/woff", "application/font-woff", "application/x-font-woff"],
|
|
24
|
+
woff2: ["font/woff2", "application/font-woff2", "application/x-font-woff2"],
|
|
25
|
+
ttf: ["font/ttf", "application/font-ttf", "application/x-font-ttf"],
|
|
26
|
+
otf: ["font/otf", "application/font-otf", "application/x-font-otf"],
|
|
27
|
+
eot: ["application/vnd.ms-fontobject"]
|
|
28
|
+
};
|
|
29
|
+
var JS_CSS_CACHE_NAME_PREFIX = "cache-js-css-";
|
|
30
|
+
var STATIC_ASSETS_CACHE_NAME_PREFIX = "cache-static-assets-";
|
|
31
|
+
var ASSET_CACHE_NAME_PREFIXES = [
|
|
32
|
+
JS_CSS_CACHE_NAME_PREFIX,
|
|
33
|
+
STATIC_ASSETS_CACHE_NAME_PREFIX
|
|
34
|
+
];
|
|
35
|
+
function normalizeMimeType(contentType) {
|
|
36
|
+
return (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
function getKnownStaticExtension(url) {
|
|
39
|
+
let pathname;
|
|
40
|
+
try {
|
|
41
|
+
pathname = new URL(url).pathname;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
46
|
+
const dotIndex = lastSegment.lastIndexOf(".");
|
|
47
|
+
if (dotIndex <= 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const extension = lastSegment.slice(dotIndex + 1).toLowerCase();
|
|
51
|
+
return Object.prototype.hasOwnProperty.call(STATIC_MIME_RULES, extension) ? extension : null;
|
|
52
|
+
}
|
|
53
|
+
function isMimeMismatch(request, response) {
|
|
54
|
+
const extension = getKnownStaticExtension(request.url);
|
|
55
|
+
if (!extension) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const mimeType = normalizeMimeType(response.headers.get("content-type"));
|
|
59
|
+
if (!mimeType) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return !STATIC_MIME_RULES[extension].includes(mimeType);
|
|
63
|
+
}
|
|
64
|
+
var ValidateResponsePlugin = class {
|
|
65
|
+
async cacheWillUpdate({ request, response }) {
|
|
66
|
+
if (!response) {
|
|
67
|
+
console.warn("不缓存失败的响应", request.url, "No Response");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (response.status === 0) {
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
console.warn("不缓存失败的响应", request.url, response.status);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (isMimeMismatch(request, response)) {
|
|
78
|
+
console.warn(
|
|
79
|
+
"MIME 类型与资源扩展名不匹配,不缓存",
|
|
80
|
+
request.url,
|
|
81
|
+
response.headers.get("content-type")
|
|
82
|
+
);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return response;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
async function purgeMismatchedCacheEntries(cacheStorage = caches) {
|
|
89
|
+
const cacheNames = await cacheStorage.keys();
|
|
90
|
+
const assetCacheNames = cacheNames.filter(
|
|
91
|
+
(name) => ASSET_CACHE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix))
|
|
92
|
+
);
|
|
93
|
+
for (const cacheName of assetCacheNames) {
|
|
94
|
+
const cache = await cacheStorage.open(cacheName);
|
|
95
|
+
const requests = await cache.keys();
|
|
96
|
+
for (const request of requests) {
|
|
97
|
+
const cachedResponse = await cache.match(request);
|
|
98
|
+
if (cachedResponse && isMimeMismatch(request, cachedResponse)) {
|
|
99
|
+
await cache.delete(request);
|
|
100
|
+
console.warn("[ServiceWorker] 清理 MIME 不匹配的缓存条目", cacheName, request.url);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
JS_CSS_CACHE_NAME_PREFIX,
|
|
107
|
+
STATIC_ASSETS_CACHE_NAME_PREFIX,
|
|
108
|
+
ValidateResponsePlugin,
|
|
109
|
+
isMimeMismatch,
|
|
110
|
+
purgeMismatchedCacheEntries
|
|
111
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CachedResponseWillBeUsedCallbackParam, FetchDidFailCallbackParam, FetchDidSucceedCallbackParam, HandlerDidCompleteCallbackParam, HandlerDidRespondCallbackParam, HandlerWillStartCallbackParam, RequestWillFetchCallbackParam, WorkboxPlugin } from 'workbox-core/types';
|
|
2
|
+
/**
|
|
3
|
+
* 仅观察 script/style 的原始 SWR 请求。state 由 Workbox 为每个 handler
|
|
4
|
+
* 单独创建;不要把请求状态挂到共享 plugin 实例上。
|
|
5
|
+
*/
|
|
6
|
+
export declare class ChunkDiagnosticsPlugin implements WorkboxPlugin {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options: {
|
|
9
|
+
release: string;
|
|
10
|
+
});
|
|
11
|
+
handlerWillStart({ request, state }: HandlerWillStartCallbackParam): Promise<void>;
|
|
12
|
+
requestWillFetch({ request, state }: RequestWillFetchCallbackParam): Promise<Request>;
|
|
13
|
+
cachedResponseWillBeUsed({ cachedResponse, state }: CachedResponseWillBeUsedCallbackParam): Promise<Response | undefined>;
|
|
14
|
+
fetchDidSucceed({ response, state }: FetchDidSucceedCallbackParam): Promise<Response>;
|
|
15
|
+
fetchDidFail({ error, state }: FetchDidFailCallbackParam): Promise<void>;
|
|
16
|
+
handlerDidRespond(options: HandlerDidRespondCallbackParam): Promise<void>;
|
|
17
|
+
handlerDidComplete(options: HandlerDidCompleteCallbackParam): Promise<void>;
|
|
18
|
+
private report;
|
|
19
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/sw-chunk-diagnostics.ts
|
|
2
|
+
import {
|
|
3
|
+
CHUNK_TRACE_MESSAGE,
|
|
4
|
+
createChunkDiagnosticId,
|
|
5
|
+
sanitizeChunkUrl
|
|
6
|
+
} from "./shared/chunk-diagnostics";
|
|
7
|
+
function safeErrorName(error) {
|
|
8
|
+
return /^(Error|TypeError|RangeError|AbortError|NetworkError|TimeoutError|SecurityError|InvalidStateError|QuotaExceededError|no-response)$/.test(
|
|
9
|
+
error.name
|
|
10
|
+
) ? error.name : "Error";
|
|
11
|
+
}
|
|
12
|
+
function responseMime(response) {
|
|
13
|
+
return (response.headers.get("content-type") || "").split(";", 1)[0].trim();
|
|
14
|
+
}
|
|
15
|
+
var ChunkDiagnosticsPlugin = class {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
}
|
|
19
|
+
async handlerWillStart({ request, state }) {
|
|
20
|
+
try {
|
|
21
|
+
if (!state || request.destination !== "script" && request.destination !== "style") {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
state.trace = {
|
|
25
|
+
requestId: createChunkDiagnosticId(),
|
|
26
|
+
at: Date.now(),
|
|
27
|
+
resource: sanitizeChunkUrl(request.url),
|
|
28
|
+
phase: "response",
|
|
29
|
+
destination: request.destination,
|
|
30
|
+
mode: request.mode,
|
|
31
|
+
credentials: request.credentials,
|
|
32
|
+
cache: "unknown",
|
|
33
|
+
network: "pending",
|
|
34
|
+
swRelease: this.options.release
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async requestWillFetch({ request, state }) {
|
|
40
|
+
try {
|
|
41
|
+
const trace = state == null ? void 0 : state.trace;
|
|
42
|
+
if (trace) {
|
|
43
|
+
trace.mode = request.mode;
|
|
44
|
+
trace.credentials = request.credentials;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
return request;
|
|
49
|
+
}
|
|
50
|
+
async cachedResponseWillBeUsed({ cachedResponse, state }) {
|
|
51
|
+
try {
|
|
52
|
+
const trace = state == null ? void 0 : state.trace;
|
|
53
|
+
if (trace)
|
|
54
|
+
trace.cache = cachedResponse ? "hit" : "miss";
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
return cachedResponse;
|
|
58
|
+
}
|
|
59
|
+
async fetchDidSucceed({ response, state }) {
|
|
60
|
+
try {
|
|
61
|
+
const trace = state == null ? void 0 : state.trace;
|
|
62
|
+
if (trace) {
|
|
63
|
+
trace.network = "response";
|
|
64
|
+
trace.networkStatus = response.status;
|
|
65
|
+
trace.networkType = response.type;
|
|
66
|
+
trace.networkMime = responseMime(response);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
return response;
|
|
71
|
+
}
|
|
72
|
+
async fetchDidFail({ error, state }) {
|
|
73
|
+
try {
|
|
74
|
+
const trace = state == null ? void 0 : state.trace;
|
|
75
|
+
if (trace) {
|
|
76
|
+
trace.network = "error";
|
|
77
|
+
trace.errorName = safeErrorName(error);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async handlerDidRespond(options) {
|
|
83
|
+
this.report(options, "response");
|
|
84
|
+
}
|
|
85
|
+
async handlerDidComplete(options) {
|
|
86
|
+
this.report(options, "complete");
|
|
87
|
+
}
|
|
88
|
+
report({ event, response, state }, phase) {
|
|
89
|
+
try {
|
|
90
|
+
const diagnostic = state;
|
|
91
|
+
const trace = diagnostic == null ? void 0 : diagnostic.trace;
|
|
92
|
+
const sentKey = phase === "response" ? "responseSent" : "completeSent";
|
|
93
|
+
if (!diagnostic || !trace || diagnostic[sentKey])
|
|
94
|
+
return;
|
|
95
|
+
diagnostic[sentKey] = true;
|
|
96
|
+
const snapshot = {
|
|
97
|
+
...trace,
|
|
98
|
+
phase,
|
|
99
|
+
...phase === "complete" ? { completedAt: Date.now() } : {},
|
|
100
|
+
servedFrom: response ? trace.cache === "hit" ? "cache" : "network" : "none",
|
|
101
|
+
...response ? {
|
|
102
|
+
responseStatus: response.status,
|
|
103
|
+
responseType: response.type,
|
|
104
|
+
responseMime: responseMime(response)
|
|
105
|
+
} : {}
|
|
106
|
+
};
|
|
107
|
+
const clientId = event.clientId;
|
|
108
|
+
if (!clientId)
|
|
109
|
+
return;
|
|
110
|
+
const delivery = (async () => {
|
|
111
|
+
try {
|
|
112
|
+
const client = await self.clients.get(clientId);
|
|
113
|
+
client == null ? void 0 : client.postMessage({ type: CHUNK_TRACE_MESSAGE, payload: snapshot });
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
event.waitUntil(delivery);
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
export {
|
|
123
|
+
ChunkDiagnosticsPlugin
|
|
124
|
+
};
|