@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
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @microbt/pwa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
帮助中心(cinmoore-support-center 0.15.16)的 Service Worker 运行时与页面端注册,原样抽成的 npm 包。行为契约与原实现逐字一致(缓存名、消息类型、注册地址格式、计数名、退避参数)。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @microbt/pwa
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
需要 peer 依赖 `@microbt/environment`(ARMS 上报)。构建侧仍由接入方自备 workbox 的 `InjectManifest` 插件(配置不用改,见下)。
|
|
12
|
+
|
|
13
|
+
四个入口:`.`(页面端注册)、`./protocol`(诊断协议,纯常量与纯函数,依赖链不含 `@microbt/environment`)、`./sw`(SW 端运行时)、`./recovery`(chunk 恢复与诊断上报,见下节)。
|
|
14
|
+
|
|
15
|
+
## 接入
|
|
16
|
+
|
|
17
|
+
**1. SW 侧**:`src/sw.ts` 三行写法(`InjectManifest` 仍由接入方配置,注入点保持在接入方产物中,包内不含 `__WB_MANIFEST`):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { setupServiceWorker } from '@microbt/pwa/sw';
|
|
21
|
+
|
|
22
|
+
declare const RELEASE: string;
|
|
23
|
+
console.log(self.__WB_MANIFEST); // InjectManifest 注入点,保留原样
|
|
24
|
+
setupServiceWorker({ release: typeof RELEASE === 'undefined' ? '' : RELEASE });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**2. 页面侧**(如 `bootstrap.ts`,原 `schedulePwaRegistration()` 无参调用改为传参):
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { schedulePwaRegistration } from '@microbt/pwa';
|
|
31
|
+
|
|
32
|
+
schedulePwaRegistration({ version: RELEASE, log });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**3. 协议符号**(诊断协议 `CHUNK_TRACE_MESSAGE`、`sanitizeChunkUrl`、`createChunkDiagnosticId`、`type ChunkRequestTrace`)从子入口导入:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { CHUNK_TRACE_MESSAGE } from '@microbt/pwa/protocol';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## chunk 恢复与诊断上报(`./recovery`)
|
|
42
|
+
|
|
43
|
+
在 `bootstrap` 里尽早调用 `setupChunkErrorRecovery`,把恢复事件交给 `createChunkDiagnosticReporter`;ARMS 计数(`arms.sum`)由接入方回调自己做:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createChunkDiagnosticReporter, setupChunkErrorRecovery } from '@microbt/pwa/recovery';
|
|
47
|
+
|
|
48
|
+
const reporter = createChunkDiagnosticReporter({
|
|
49
|
+
release: RELEASE,
|
|
50
|
+
appVersion: () => appVersion().version,
|
|
51
|
+
});
|
|
52
|
+
setupChunkErrorRecovery((event) => {
|
|
53
|
+
arms.sum(`CHUNK-RECOVERY-${event.outcome}`); // 计数留在接入方
|
|
54
|
+
reporter.report(event);
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
完成信号探针由接入方写:Umi 叶子路由提交时调 `markChunkRecoveryRouteReady('page')`;qiankun 子应用 `afterMount` 调 `markChunkRecoveryRouteReady('micro-app')`。
|
|
59
|
+
|
|
60
|
+
行为契约(以下字符串/数值一字不差):
|
|
61
|
+
|
|
62
|
+
- sessionStorage 键:`chunk-error-last-reload-at`、`chunk-error-pending-recovery`、`chunk-diagnostic-outbox-v1`
|
|
63
|
+
- chunk 失败识别正则 `CHUNK_ERROR_PATTERN` 原文:`/ChunkLoadError|CSS_CHUNK_LOAD_FAILED|Loading (?:CSS )?chunk \S+ failed|error loading dynamically imported module/i`
|
|
64
|
+
- reload 闸门 60 秒;待恢复记录 5 分钟;诊断有效期 5 分钟;发件箱 16 条、补发重试 30 次 × 1 秒;SW 请求记录 40 条;关联窗口 120 秒取最近 3 条;单条载荷 < 16,000 字符;去重集合上限 128
|
|
65
|
+
- 结果名:`RELOAD`、`SKIP-OFFLINE`、`SKIP-GUARD`、`SKIP-STORAGE`、`REENTERED`、`ROUTE-READY`、`MICRO-APP-READY`、`ROUTE-REDIRECTED`、`RECURRED`;SW 侧 `SW-REQUEST`
|
|
66
|
+
- ARMS:`custom` 的 `kind: 'chunk-diagnostic'`、`schema: 1`、`eventId`、`attemptId`、`outcome`、`replay`、`payload`;行为日志 `name: 'chunk-recovery'`、正文只有 `{id, outcome}`;`correlation: 'resource-time-window'`;`reconstructedFrom: 'REENTERED'`
|
|
67
|
+
|
|
68
|
+
依赖链约束:`./recovery` 的依赖链(含 side-effect import)不含 `@microbt/environment`、任何 `workbox-*`,也不含模块加载期就读 `window`/`document` 的模块——它要在首屏最早加载,自己不能被别的依赖拖垮。
|
|
69
|
+
|
|
70
|
+
## 行为契约
|
|
71
|
+
|
|
72
|
+
以下字符串/参数与帮助中心 0.15.16 一字不差:
|
|
73
|
+
|
|
74
|
+
- 缓存名前缀 `cache-pages-`、`cache-static-assets-`、`cache-js-css-`;激活时 `caches.delete('pages')`
|
|
75
|
+
- 消息类型 `SW_ERROR`、`SW_UNHANDLED_REJECTION`、`SW_CHUNK_REQUEST_TRACE`
|
|
76
|
+
- 注册地址 `/sw.js?baseUrl=<CDN 版本目录>&v=<构建版本>`(`v` 随构建版本变化是离线 SW 更新通道)
|
|
77
|
+
- 计数名 `PWA-*` 全系;行为名 `pwa-register-retry`(退避 30s → 2min → 8min,共 3 次)
|
|
78
|
+
- 导航请求 networkTimeoutSeconds 3;`requestIdleCallback` 超时 3000ms
|
|
79
|
+
- `schedulePwaRegistration` 单例语义:同一页面第二次调用不重复调度
|
|
80
|
+
|
|
81
|
+
## 开发
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pnpm install
|
|
85
|
+
pnpm test # vitest run
|
|
86
|
+
pnpm build # father build(dist/esm)
|
|
87
|
+
pnpm lint # eslint
|
|
88
|
+
```
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createChunkDiagnosticReporter } from './shared/chunk-diagnostic-reporter';
|
|
2
|
+
export { markChunkRecoveryRouteReady, setupChunkErrorRecovery, } from './shared/chunk-error-recovery';
|
|
3
|
+
export type { ChunkRecoveryEvent, RecoveryCompletionKind } from './shared/chunk-error-recovery';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/recovery.ts
|
|
2
|
+
import { createChunkDiagnosticReporter } from "./shared/chunk-diagnostic-reporter";
|
|
3
|
+
import {
|
|
4
|
+
markChunkRecoveryRouteReady,
|
|
5
|
+
setupChunkErrorRecovery
|
|
6
|
+
} from "./shared/chunk-error-recovery";
|
|
7
|
+
export {
|
|
8
|
+
createChunkDiagnosticReporter,
|
|
9
|
+
markChunkRecoveryRouteReady,
|
|
10
|
+
setupChunkErrorRecovery
|
|
11
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface DiagnosticRecord {
|
|
2
|
+
eventId: string;
|
|
3
|
+
attemptId: string;
|
|
4
|
+
at: number;
|
|
5
|
+
outcome: string;
|
|
6
|
+
payload: string;
|
|
7
|
+
}
|
|
8
|
+
/** custom() 不提供服务端 ACK:当前记录保留到下个文档再发一次,由 eventId 去重。 */
|
|
9
|
+
export declare function createDiagnosticOutbox(): {
|
|
10
|
+
has: (eventId: string) => boolean;
|
|
11
|
+
enqueue(record: DiagnosticRecord, replay?: boolean): void;
|
|
12
|
+
dispose(): void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/shared/chunk-diagnostic-outbox.ts
|
|
2
|
+
import { DIAGNOSTIC_ID, DIAGNOSTIC_TTL, sanitizeStoredPayload } from "./chunk-diagnostic-payload";
|
|
3
|
+
var OUTBOX_KEY = "chunk-diagnostic-outbox-v1";
|
|
4
|
+
var MAX_RECORDS = 16;
|
|
5
|
+
var OUTCOMES = /* @__PURE__ */ new Set([
|
|
6
|
+
"RELOAD",
|
|
7
|
+
"REENTERED",
|
|
8
|
+
"ROUTE-READY",
|
|
9
|
+
"MICRO-APP-READY",
|
|
10
|
+
"ROUTE-REDIRECTED",
|
|
11
|
+
"RECURRED",
|
|
12
|
+
"SKIP-OFFLINE",
|
|
13
|
+
"SKIP-GUARD",
|
|
14
|
+
"SKIP-STORAGE",
|
|
15
|
+
"SW-REQUEST"
|
|
16
|
+
]);
|
|
17
|
+
function restore() {
|
|
18
|
+
try {
|
|
19
|
+
const saved = JSON.parse(window.sessionStorage.getItem(OUTBOX_KEY) || "[]");
|
|
20
|
+
if (!Array.isArray(saved))
|
|
21
|
+
return [];
|
|
22
|
+
const records = [];
|
|
23
|
+
for (const r of saved.slice(-MAX_RECORDS)) {
|
|
24
|
+
try {
|
|
25
|
+
if (!r || typeof r.eventId !== "string" || !DIAGNOSTIC_ID.test(r.eventId) || typeof r.attemptId !== "string" || !DIAGNOSTIC_ID.test(r.attemptId) || !Number.isFinite(r.at) || Date.now() < r.at || Date.now() - r.at >= DIAGNOSTIC_TTL || !OUTCOMES.has(r.outcome) || typeof r.payload !== "string" || r.payload.length >= 16e3)
|
|
26
|
+
continue;
|
|
27
|
+
records.push({
|
|
28
|
+
eventId: r.eventId,
|
|
29
|
+
attemptId: r.attemptId,
|
|
30
|
+
at: r.at,
|
|
31
|
+
outcome: r.outcome,
|
|
32
|
+
payload: sanitizeStoredPayload(r.payload)
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return records;
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function createDiagnosticOutbox() {
|
|
43
|
+
const delivered = /* @__PURE__ */ new Set();
|
|
44
|
+
const breadcrumbs = /* @__PURE__ */ new Set();
|
|
45
|
+
let queue = restore();
|
|
46
|
+
const restored = new Set(queue.map((r) => r.eventId));
|
|
47
|
+
let timer;
|
|
48
|
+
let retries = 0;
|
|
49
|
+
let disposed = false;
|
|
50
|
+
const persist = () => {
|
|
51
|
+
try {
|
|
52
|
+
window.sessionStorage.setItem(OUTBOX_KEY, JSON.stringify(queue));
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const needsBehavior = (r) => r.outcome !== "SW-REQUEST" && !breadcrumbs.has(r.eventId);
|
|
57
|
+
const flush = () => {
|
|
58
|
+
if (disposed)
|
|
59
|
+
return;
|
|
60
|
+
queue = queue.filter((r) => Date.now() - r.at < DIAGNOSTIC_TTL && Date.now() >= r.at);
|
|
61
|
+
for (const record of queue) {
|
|
62
|
+
const logger = window.__bl;
|
|
63
|
+
try {
|
|
64
|
+
if (!delivered.has(record.eventId) && typeof (logger == null ? void 0 : logger.custom) === "function") {
|
|
65
|
+
logger.custom(
|
|
66
|
+
{
|
|
67
|
+
kind: "chunk-diagnostic",
|
|
68
|
+
schema: 1,
|
|
69
|
+
eventId: record.eventId,
|
|
70
|
+
attemptId: record.attemptId,
|
|
71
|
+
outcome: record.outcome,
|
|
72
|
+
replay: restored.has(record.eventId) ? 1 : 0,
|
|
73
|
+
payload: record.payload
|
|
74
|
+
},
|
|
75
|
+
1
|
|
76
|
+
);
|
|
77
|
+
delivered.add(record.eventId);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (needsBehavior(record) && typeof (logger == null ? void 0 : logger.addBehavior) === "function" && typeof logger.reportBehavior === "function") {
|
|
83
|
+
logger.addBehavior({
|
|
84
|
+
data: {
|
|
85
|
+
name: "chunk-recovery",
|
|
86
|
+
message: JSON.stringify({
|
|
87
|
+
id: record.attemptId,
|
|
88
|
+
outcome: record.outcome
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
page: JSON.parse(record.payload).page || ""
|
|
92
|
+
});
|
|
93
|
+
logger.reportBehavior();
|
|
94
|
+
breadcrumbs.add(record.eventId);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
queue = queue.filter(
|
|
100
|
+
(r) => !(restored.has(r.eventId) && delivered.has(r.eventId) && !needsBehavior(r))
|
|
101
|
+
);
|
|
102
|
+
persist();
|
|
103
|
+
if (queue.some((r) => !delivered.has(r.eventId) || needsBehavior(r)) && retries < 30 && !timer) {
|
|
104
|
+
retries += 1;
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
timer = void 0;
|
|
107
|
+
flush();
|
|
108
|
+
}, 1e3);
|
|
109
|
+
}
|
|
110
|
+
for (const set of [delivered, breadcrumbs, restored])
|
|
111
|
+
while (set.size > 128)
|
|
112
|
+
set.delete(set.values().next().value);
|
|
113
|
+
};
|
|
114
|
+
const has = (eventId) => delivered.has(eventId) || queue.some((r) => r.eventId === eventId);
|
|
115
|
+
flush();
|
|
116
|
+
return {
|
|
117
|
+
has,
|
|
118
|
+
enqueue(record, replay = false) {
|
|
119
|
+
if (disposed || has(record.eventId))
|
|
120
|
+
return;
|
|
121
|
+
if (replay)
|
|
122
|
+
restored.add(record.eventId);
|
|
123
|
+
queue = [...queue, record].slice(-MAX_RECORDS);
|
|
124
|
+
retries = 0;
|
|
125
|
+
persist();
|
|
126
|
+
flush();
|
|
127
|
+
},
|
|
128
|
+
dispose() {
|
|
129
|
+
disposed = true;
|
|
130
|
+
if (timer)
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export {
|
|
136
|
+
createDiagnosticOutbox
|
|
137
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ChunkRequestTrace } from './chunk-diagnostics';
|
|
2
|
+
export declare const DIAGNOSTIC_TTL: number;
|
|
3
|
+
export declare const DIAGNOSTIC_ID: RegExp;
|
|
4
|
+
/** 可选浏览器/WebView API 不可用时仍保留核心错误事件。 */
|
|
5
|
+
export declare function diagnosticContext(release: string, appVersion: () => string): {
|
|
6
|
+
release: string;
|
|
7
|
+
appVersion: string;
|
|
8
|
+
online: boolean;
|
|
9
|
+
visibility: string;
|
|
10
|
+
swControlled: boolean;
|
|
11
|
+
swScript: string;
|
|
12
|
+
nativeResourceSource: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function diagnosticTiming(resource: string, occurredAt: number): Record<string, string | number> | undefined;
|
|
15
|
+
/** 消息和 storage 都只接受协议字段,不夹带 token、正文或任意扩展属性。 */
|
|
16
|
+
export declare function readChunkTrace(value: unknown): ChunkRequestTrace | undefined;
|
|
17
|
+
export declare function sanitizeStoredPayload(payload: string): string;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// src/shared/chunk-diagnostic-payload.ts
|
|
2
|
+
import { sanitizeChunkUrl } from "./chunk-diagnostics";
|
|
3
|
+
var DIAGNOSTIC_TTL = 5 * 6e4;
|
|
4
|
+
var DIAGNOSTIC_ID = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
5
|
+
var safeVersion = (value) => typeof value === "string" ? value.replace(/[^\w.-]/g, "").slice(0, 80) : "unknown";
|
|
6
|
+
var visibility = (value) => value === "visible" || value === "hidden" ? value : "unknown";
|
|
7
|
+
function diagnosticContext(release, appVersion) {
|
|
8
|
+
var _a;
|
|
9
|
+
let version = "unknown";
|
|
10
|
+
let controller;
|
|
11
|
+
try {
|
|
12
|
+
version = safeVersion(appVersion());
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
controller = (_a = navigator.serviceWorker) == null ? void 0 : _a.controller;
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
release: safeVersion(release),
|
|
21
|
+
appVersion: version,
|
|
22
|
+
online: navigator.onLine,
|
|
23
|
+
visibility: typeof document === "undefined" ? "unknown" : visibility(document.visibilityState),
|
|
24
|
+
swControlled: Boolean(controller),
|
|
25
|
+
swScript: controller ? sanitizeChunkUrl(controller.scriptURL) : "",
|
|
26
|
+
nativeResourceSource: "unavailable"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function safeTiming(value) {
|
|
30
|
+
const result = {
|
|
31
|
+
visibility: "may-be-redacted"
|
|
32
|
+
};
|
|
33
|
+
for (const key of [
|
|
34
|
+
"startTime",
|
|
35
|
+
"duration",
|
|
36
|
+
"transferSize",
|
|
37
|
+
"encodedBodySize",
|
|
38
|
+
"decodedBodySize",
|
|
39
|
+
"workerStart",
|
|
40
|
+
"status",
|
|
41
|
+
"completedAt"
|
|
42
|
+
]) {
|
|
43
|
+
const number = value[key];
|
|
44
|
+
if (typeof number === "number" && Number.isFinite(number) && number >= 0)
|
|
45
|
+
result[key] = number;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value.initiatorType === "string" && /^(link|script|css|fetch|other)$/.test(value.initiatorType))
|
|
48
|
+
result.initiatorType = value.initiatorType;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function diagnosticTiming(resource, occurredAt) {
|
|
52
|
+
var _a;
|
|
53
|
+
try {
|
|
54
|
+
const origin = performance.timeOrigin || ((_a = performance.timing) == null ? void 0 : _a.navigationStart);
|
|
55
|
+
if (!Number.isFinite(origin) || origin <= 0)
|
|
56
|
+
return void 0;
|
|
57
|
+
const entries = performance.getEntriesByType("resource");
|
|
58
|
+
const entry = entries.filter(
|
|
59
|
+
(e) => sanitizeChunkUrl(e.name) === resource && Number.isFinite(e.startTime) && Number.isFinite(e.duration) && Math.abs(origin + e.startTime + e.duration - occurredAt) < 12e4
|
|
60
|
+
).pop();
|
|
61
|
+
if (!entry)
|
|
62
|
+
return void 0;
|
|
63
|
+
return safeTiming({
|
|
64
|
+
startTime: entry.startTime,
|
|
65
|
+
duration: entry.duration,
|
|
66
|
+
completedAt: origin + entry.startTime + entry.duration,
|
|
67
|
+
transferSize: entry.transferSize,
|
|
68
|
+
encodedBodySize: entry.encodedBodySize,
|
|
69
|
+
decodedBodySize: entry.decodedBodySize,
|
|
70
|
+
workerStart: entry.workerStart,
|
|
71
|
+
initiatorType: entry.initiatorType,
|
|
72
|
+
status: entry.responseStatus
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function readChunkTrace(value) {
|
|
79
|
+
if (!value || typeof value !== "object")
|
|
80
|
+
return;
|
|
81
|
+
const r = value;
|
|
82
|
+
if (typeof r.requestId !== "string" || !DIAGNOSTIC_ID.test(r.requestId) || !Number.isFinite(r.at) || Math.abs(Date.now() - r.at) > DIAGNOSTIC_TTL || !["response", "complete"].includes(r.phase))
|
|
83
|
+
return;
|
|
84
|
+
const resource = sanitizeChunkUrl(r.resource);
|
|
85
|
+
if (!resource)
|
|
86
|
+
return;
|
|
87
|
+
const result = {
|
|
88
|
+
requestId: r.requestId,
|
|
89
|
+
at: r.at,
|
|
90
|
+
resource,
|
|
91
|
+
phase: r.phase,
|
|
92
|
+
destination: ["script", "style"].includes(r.destination) ? r.destination : "unknown",
|
|
93
|
+
mode: ["cors", "no-cors", "same-origin"].includes(r.mode) ? r.mode : "unknown",
|
|
94
|
+
credentials: ["include", "omit", "same-origin"].includes(r.credentials) ? r.credentials : "unknown",
|
|
95
|
+
cache: ["hit", "miss"].includes(r.cache) ? r.cache : "unknown",
|
|
96
|
+
network: ["pending", "response", "error"].includes(r.network) ? r.network : "pending",
|
|
97
|
+
swRelease: safeVersion(r.swRelease)
|
|
98
|
+
};
|
|
99
|
+
for (const key of ["networkStatus", "responseStatus"])
|
|
100
|
+
if (Number.isInteger(r[key]) && r[key] >= 0 && r[key] <= 599)
|
|
101
|
+
result[key] = r[key];
|
|
102
|
+
if (Number.isFinite(r.completedAt) && r.completedAt >= r.at && r.completedAt <= Date.now() + 1e3)
|
|
103
|
+
result.completedAt = r.completedAt;
|
|
104
|
+
for (const key of ["networkType", "responseType"])
|
|
105
|
+
if (["basic", "cors", "default", "error", "opaque", "opaqueredirect"].includes(r[key] || ""))
|
|
106
|
+
result[key] = r[key];
|
|
107
|
+
for (const key of ["networkMime", "responseMime"]) {
|
|
108
|
+
const mime = typeof r[key] === "string" ? r[key].split(";", 1)[0].trim() : "";
|
|
109
|
+
if (/^[\w.+-]+\/[\w.+-]+$/.test(mime))
|
|
110
|
+
result[key] = mime.slice(0, 80);
|
|
111
|
+
}
|
|
112
|
+
if (/^(Error|TypeError|RangeError|AbortError|NetworkError|TimeoutError|SecurityError|InvalidStateError|QuotaExceededError|no-response)$/.test(
|
|
113
|
+
r.errorName || ""
|
|
114
|
+
))
|
|
115
|
+
result.errorName = r.errorName;
|
|
116
|
+
if (["cache", "network", "none"].includes(r.servedFrom || ""))
|
|
117
|
+
result.servedFrom = r.servedFrom;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function sanitizeStoredPayload(payload) {
|
|
121
|
+
const r = JSON.parse(payload);
|
|
122
|
+
if (!r || typeof r !== "object" || Array.isArray(r))
|
|
123
|
+
throw new Error("Invalid diagnostic payload");
|
|
124
|
+
const result = {};
|
|
125
|
+
for (const key of ["page", "resource", "observedResource", "diagnosticResource"])
|
|
126
|
+
if (typeof r[key] === "string")
|
|
127
|
+
result[key] = sanitizeChunkUrl(r[key]);
|
|
128
|
+
for (const key of ["attemptId", "outcome", "source", "observedSource"])
|
|
129
|
+
if (typeof r[key] === "string" && DIAGNOSTIC_ID.test(r[key]))
|
|
130
|
+
result[key] = r[key];
|
|
131
|
+
for (const key of ["at", "occurredAt"])
|
|
132
|
+
if (Number.isFinite(r[key]))
|
|
133
|
+
result[key] = r[key];
|
|
134
|
+
if (r.reconstructedFrom === "REENTERED")
|
|
135
|
+
result.reconstructedFrom = "REENTERED";
|
|
136
|
+
if (r.context && typeof r.context === "object")
|
|
137
|
+
result.context = {
|
|
138
|
+
release: safeVersion(r.context.release),
|
|
139
|
+
appVersion: safeVersion(r.context.appVersion),
|
|
140
|
+
online: typeof r.context.online === "boolean" ? r.context.online : void 0,
|
|
141
|
+
visibility: visibility(r.context.visibility),
|
|
142
|
+
swControlled: r.context.swControlled === true,
|
|
143
|
+
swScript: sanitizeChunkUrl(r.context.swScript || ""),
|
|
144
|
+
nativeResourceSource: "unavailable"
|
|
145
|
+
};
|
|
146
|
+
if (r.timing && typeof r.timing === "object")
|
|
147
|
+
result.timing = safeTiming(r.timing);
|
|
148
|
+
if (Array.isArray(r.swTraces))
|
|
149
|
+
result.swTraces = r.swTraces.slice(-3).map(readChunkTrace).filter(Boolean);
|
|
150
|
+
if (r.trace)
|
|
151
|
+
result.trace = readChunkTrace(r.trace);
|
|
152
|
+
result.correlation = "resource-time-window";
|
|
153
|
+
return JSON.stringify(result);
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
DIAGNOSTIC_ID,
|
|
157
|
+
DIAGNOSTIC_TTL,
|
|
158
|
+
diagnosticContext,
|
|
159
|
+
diagnosticTiming,
|
|
160
|
+
readChunkTrace,
|
|
161
|
+
sanitizeStoredPayload
|
|
162
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChunkRecoveryEvent } from './chunk-error-recovery';
|
|
2
|
+
/** 完整诊断走 t=custom / x-* 字段,不制造 error 或 API 指标。 */
|
|
3
|
+
export declare function createChunkDiagnosticReporter(options: {
|
|
4
|
+
release: string;
|
|
5
|
+
appVersion?: () => string;
|
|
6
|
+
}): {
|
|
7
|
+
report: (event: ChunkRecoveryEvent) => void;
|
|
8
|
+
dispose: () => void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/shared/chunk-diagnostic-reporter.ts
|
|
2
|
+
import { createDiagnosticOutbox } from "./chunk-diagnostic-outbox";
|
|
3
|
+
import {
|
|
4
|
+
DIAGNOSTIC_TTL,
|
|
5
|
+
diagnosticContext,
|
|
6
|
+
diagnosticTiming,
|
|
7
|
+
readChunkTrace
|
|
8
|
+
} from "./chunk-diagnostic-payload";
|
|
9
|
+
import { CHUNK_TRACE_MESSAGE, sanitizeChunkUrl } from "./chunk-diagnostics";
|
|
10
|
+
function createChunkDiagnosticReporter(options) {
|
|
11
|
+
const { release, appVersion = () => "unknown" } = options;
|
|
12
|
+
const outbox = createDiagnosticOutbox();
|
|
13
|
+
let traces = [];
|
|
14
|
+
let active;
|
|
15
|
+
const diagnosticResource = (event) => sanitizeChunkUrl(event.observedResource || event.resource);
|
|
16
|
+
const matching = (event) => traces.filter(
|
|
17
|
+
(trace) => trace.resource === diagnosticResource(event) && Math.abs((trace.completedAt ?? trace.at) - event.occurredAt) < 12e4
|
|
18
|
+
).slice(-3);
|
|
19
|
+
const report = (event) => {
|
|
20
|
+
try {
|
|
21
|
+
active = event;
|
|
22
|
+
const safeEvent = {
|
|
23
|
+
attemptId: event.attemptId,
|
|
24
|
+
at: event.at,
|
|
25
|
+
occurredAt: event.occurredAt,
|
|
26
|
+
outcome: event.outcome,
|
|
27
|
+
source: event.source,
|
|
28
|
+
observedSource: event.observedSource,
|
|
29
|
+
page: sanitizeChunkUrl(event.page),
|
|
30
|
+
resource: sanitizeChunkUrl(event.resource),
|
|
31
|
+
observedResource: event.observedResource ? sanitizeChunkUrl(event.observedResource) : void 0
|
|
32
|
+
};
|
|
33
|
+
if (event.outcome === "REENTERED" && !outbox.has(`${event.attemptId}-RELOAD`)) {
|
|
34
|
+
outbox.enqueue(
|
|
35
|
+
{
|
|
36
|
+
eventId: `${event.attemptId}-RELOAD`,
|
|
37
|
+
attemptId: event.attemptId,
|
|
38
|
+
at: Date.now(),
|
|
39
|
+
outcome: "RELOAD",
|
|
40
|
+
payload: JSON.stringify({
|
|
41
|
+
attemptId: event.attemptId,
|
|
42
|
+
at: event.at,
|
|
43
|
+
page: safeEvent.page,
|
|
44
|
+
resource: safeEvent.resource,
|
|
45
|
+
source: event.source,
|
|
46
|
+
outcome: "RELOAD",
|
|
47
|
+
reconstructedFrom: "REENTERED"
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
true
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
outbox.enqueue({
|
|
54
|
+
eventId: `${event.attemptId}-${event.outcome}`,
|
|
55
|
+
attemptId: event.attemptId,
|
|
56
|
+
at: Date.now(),
|
|
57
|
+
outcome: event.outcome,
|
|
58
|
+
payload: JSON.stringify({
|
|
59
|
+
...safeEvent,
|
|
60
|
+
diagnosticResource: diagnosticResource(event),
|
|
61
|
+
context: diagnosticContext(release, appVersion),
|
|
62
|
+
timing: diagnosticTiming(diagnosticResource(event), event.occurredAt),
|
|
63
|
+
swTraces: matching(event),
|
|
64
|
+
correlation: "resource-time-window"
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const onMessage = (message) => {
|
|
71
|
+
var _a, _b;
|
|
72
|
+
try {
|
|
73
|
+
if (!message.source || message.source !== ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller) || ((_b = message.data) == null ? void 0 : _b.type) !== CHUNK_TRACE_MESSAGE)
|
|
74
|
+
return;
|
|
75
|
+
const trace = readChunkTrace(message.data.payload);
|
|
76
|
+
if (!trace)
|
|
77
|
+
return;
|
|
78
|
+
traces = traces.filter(
|
|
79
|
+
(r) => r.requestId !== trace.requestId && Date.now() - r.at < DIAGNOSTIC_TTL
|
|
80
|
+
);
|
|
81
|
+
traces.push(trace);
|
|
82
|
+
traces = traces.slice(-40);
|
|
83
|
+
if (active && Date.now() - active.occurredAt < DIAGNOSTIC_TTL && matching(active).includes(trace)) {
|
|
84
|
+
outbox.enqueue({
|
|
85
|
+
eventId: `${trace.requestId}-${trace.phase}`,
|
|
86
|
+
attemptId: active.attemptId,
|
|
87
|
+
at: Date.now(),
|
|
88
|
+
outcome: "SW-REQUEST",
|
|
89
|
+
payload: JSON.stringify({
|
|
90
|
+
attemptId: active.attemptId,
|
|
91
|
+
page: sanitizeChunkUrl(active.page),
|
|
92
|
+
resource: sanitizeChunkUrl(active.resource),
|
|
93
|
+
diagnosticResource: diagnosticResource(active),
|
|
94
|
+
trace,
|
|
95
|
+
correlation: "resource-time-window"
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const serviceWorker = navigator.serviceWorker;
|
|
103
|
+
serviceWorker == null ? void 0 : serviceWorker.addEventListener("message", onMessage);
|
|
104
|
+
return {
|
|
105
|
+
report,
|
|
106
|
+
dispose: () => {
|
|
107
|
+
outbox.dispose();
|
|
108
|
+
serviceWorker == null ? void 0 : serviceWorker.removeEventListener("message", onMessage);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
createChunkDiagnosticReporter
|
|
114
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 页面与 SW 共用的有限、脱敏诊断协议;不携带请求头、正文或查询参数。 */
|
|
2
|
+
export interface ChunkRequestTrace {
|
|
3
|
+
requestId: string;
|
|
4
|
+
at: number;
|
|
5
|
+
completedAt?: number;
|
|
6
|
+
resource: string;
|
|
7
|
+
phase: 'response' | 'complete';
|
|
8
|
+
destination: string;
|
|
9
|
+
mode: string;
|
|
10
|
+
credentials: string;
|
|
11
|
+
cache: 'hit' | 'miss' | 'unknown';
|
|
12
|
+
network: 'pending' | 'response' | 'error';
|
|
13
|
+
networkStatus?: number;
|
|
14
|
+
networkType?: string;
|
|
15
|
+
networkMime?: string;
|
|
16
|
+
responseStatus?: number;
|
|
17
|
+
responseType?: string;
|
|
18
|
+
responseMime?: string;
|
|
19
|
+
servedFrom?: 'cache' | 'network' | 'none';
|
|
20
|
+
errorName?: string;
|
|
21
|
+
swRelease: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const CHUNK_TRACE_MESSAGE = "SW_CHUNK_REQUEST_TRACE";
|
|
24
|
+
/** URL 凭证、query、hash 均不出本地;未知协议不记录。 */
|
|
25
|
+
export declare function sanitizeChunkUrl(value: string, base?: string): string;
|
|
26
|
+
export declare function createChunkDiagnosticId(): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/shared/chunk-diagnostics.ts
|
|
2
|
+
var CHUNK_TRACE_MESSAGE = "SW_CHUNK_REQUEST_TRACE";
|
|
3
|
+
function sanitizeChunkUrl(value, base) {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(value, base);
|
|
6
|
+
return /^https?:$/.test(url.protocol) ? `${url.origin}${url.pathname}`.slice(0, 512) : "";
|
|
7
|
+
} catch {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function createChunkDiagnosticId() {
|
|
12
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
CHUNK_TRACE_MESSAGE,
|
|
16
|
+
createChunkDiagnosticId,
|
|
17
|
+
sanitizeChunkUrl
|
|
18
|
+
};
|