@rei-standard/amsg-sw 2.1.0 → 2.2.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 +88 -2
- package/dist/index.cjs +409 -35
- package/dist/index.d.cts +495 -37
- package/dist/index.d.ts +495 -37
- package/dist/index.mjs +409 -35
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -52,7 +52,10 @@ navigator.serviceWorker.addEventListener('message', (e) => {
|
|
|
52
52
|
- `"when-hidden"`:仅当没有 `visibilityState === "visible"` 的客户端时才弹系统通知。如果应用在前台,则静默。
|
|
53
53
|
- `false`:强制不弹通知,即使是 `content`。适合完全交给应用自行接管或自绘弹窗的场景。
|
|
54
54
|
|
|
55
|
-
当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。
|
|
55
|
+
当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `renotify`, `requireInteraction`, `silent`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。
|
|
56
|
+
|
|
57
|
+
> **APNs / iOS Web Push 提醒**
|
|
58
|
+
> 如果业务大量发送后台 push 却长期不展示可见通知,iOS Web Push 的送达可能被系统策略影响。生产环境建议对后台消息使用 `notification.show = "always"` 或 `"when-hidden"`,再配合 `tag` 折叠与 `silent: true` 降低打扰。
|
|
56
59
|
|
|
57
60
|
#### 场景示例
|
|
58
61
|
|
|
@@ -88,9 +91,92 @@ navigator.serviceWorker.addEventListener('message', (e) => {
|
|
|
88
91
|
> **注意:对于 multipart 传输**
|
|
89
92
|
> 当 payload 通过 `_multipart` 分片时,未收齐前不仅不派发业务事件,也**绝不**弹系统通知。收齐并还原为原始 payload 后,再按原始 payload 的 `notification.show` 策略执行判定。
|
|
90
93
|
|
|
94
|
+
### Delivery dedupe(通知前去重)
|
|
95
|
+
|
|
96
|
+
`installReiSW()` 默认启用包级 dedupe。所有业务 payload 不管来自 Web Push、multipart 还原、blob envelope,还是页面通过 `postMessage` 桥接进 SW,都会先经过同一个 gate:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
dedupe -> notification.show 策略 -> showNotification / postMessage / onBusinessPayload
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
第一次到达的 payload 会正常走 `notification.show` 策略、窗口广播和 `onBusinessPayload`。重复 payload **不会**再次广播,也**不会**再次调用 `onBusinessPayload`;如果第一次到达时因为前台可见等原因没有展示系统通知,而后到的 Web Push backup 已经满足 `notification.show` 条件,SW 会只补一次系统通知,然后把结果放进 `onDuplicate(info)`。这层去重发生在业务落地前面,不依赖业务层 inbox 自己兜底。
|
|
103
|
+
|
|
104
|
+
默认 key 按顺序读取:
|
|
105
|
+
|
|
106
|
+
1. `payload.messageId`
|
|
107
|
+
2. `payload.id`
|
|
108
|
+
3. `payload.dedupeKey`
|
|
109
|
+
|
|
110
|
+
没有 key 时不去重,保持旧 payload 兼容。multipart 会先还原成原始 payload 再取 key;blob envelope 如果携带 `messageId` / `id` / `dedupeKey`,也会被同一套 gate 覆盖。
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
installReiSW(self, {
|
|
114
|
+
dedupe: {
|
|
115
|
+
enabled: true, // 默认 true
|
|
116
|
+
ttlMs: 10 * 60_000, // 默认 10 分钟
|
|
117
|
+
dbName: 'rei_amsg_sw_dedupe_v1', // 想隔离另一套去重数据就改这个;每个 dbName 是独立 IDB instance
|
|
118
|
+
key: (payload) => payload.messageId,
|
|
119
|
+
},
|
|
120
|
+
onDuplicate: async (info) => {
|
|
121
|
+
// { key, source, messageKind, firstSeenAt, existingSource,
|
|
122
|
+
// existingMessageKind, existingNotificationShown, duplicateNotificationShown }
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
实现使用 IndexedDB 的 `add()` + keyPath 做原子 claim:第一次 add 成功才放行;几乎同时到达的同 key payload,后到者会命中 `ConstraintError` 并作为 duplicate 返回。TTL 清理是懒清理,不需要 KV / D1 / Durable Object。
|
|
128
|
+
|
|
129
|
+
### 页面 -> SW 业务投递
|
|
130
|
+
|
|
131
|
+
SSE 默认先进页面主线程。若要让 SSE payload 和 Web Push backup 共用 SW 的 dedupe / notification / `onBusinessPayload` 管线,页面可以把 payload 转交给 SW:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const registration = await navigator.serviceWorker.ready;
|
|
135
|
+
const channel = new MessageChannel();
|
|
136
|
+
|
|
137
|
+
channel.port1.onmessage = (event) => {
|
|
138
|
+
// 成功:{ ok: true, duplicate?: boolean, key?: string, requestId?: string }
|
|
139
|
+
// 失败:{ ok: false, error: string, key?: string, requestId?: string }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
registration.active?.postMessage({
|
|
143
|
+
type: 'REI_AMSG_DELIVER',
|
|
144
|
+
source: 'sse',
|
|
145
|
+
requestId: crypto.randomUUID(),
|
|
146
|
+
payload,
|
|
147
|
+
}, [channel.port2]);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Web Push `push` event 和 `REI_AMSG_DELIVER` 最终都会进入同一个内部 pipeline。SSE 先到时,后来的 Web Push backup 会被 dedupe;Web Push 先到时,后来的 SSE bridge 也会被 dedupe。若首包已经落过业务但没弹通知,重复包只负责按当前 `notification.show` 策略补通知,不会重复触发业务回调。
|
|
151
|
+
|
|
152
|
+
### 生产推荐链路:SSE + Web Push backup + SW dedupe
|
|
153
|
+
|
|
154
|
+
0.9.0 / 2.2.0 起,正式环境推荐把“双路投递、包层去重”当作默认责任边界。`amsg-instant` 固定 `backupPush:'on'`,所以 Worker 不需要等断线才发 backup;client 收到 SSE 后应立刻桥接给 SW;SW 负责统一去重、补通知和业务落地。
|
|
155
|
+
|
|
156
|
+
| 环节 | 包配置 / 调用 | 推荐值 | 责任 |
|
|
157
|
+
|------|---------------|--------|------|
|
|
158
|
+
| Worker 侧 SSE | `createInstantHandler({ sse })` | 可省略;等价于 `backupPush:'on'`, `keepaliveMs:1_000`, `immediateKeepalive:true` | SSE 正常流式返回,同时每条 payload 都发 Web Push backup |
|
|
159
|
+
| Client 侧 SSE → SW | `consumeInstantStream(..., { onPayload })` 内立刻 `postMessage({ type:'REI_AMSG_DELIVER', payload, source:'sse', requestId })` | 强烈推荐 | 让 SSE 与 Web Push 进入同一条 SW delivery / dedupe 管线 |
|
|
160
|
+
| SW 侧 dedupe | `installReiSW(self, { dedupe })` | 可省略;默认启用,key 为 `messageId` → `id` → `dedupeKey`,TTL 10 分钟 | 先到者触发业务,后到者不重复入库;必要时只补系统通知 |
|
|
161
|
+
| 通知策略 | `payload.notification.show` | 普通内容推荐 `'when-hidden'`;低打扰更新可加 `silent:true` + `tag` | 前台交给 UI,隐藏/关闭后由 Web Push backup 补通知 |
|
|
162
|
+
|
|
163
|
+
一个最小形态:
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
installReiSW(self, {
|
|
167
|
+
defaultIcon: './icons/icon-192.png',
|
|
168
|
+
defaultBadge: './icons/icon-192.png',
|
|
169
|
+
multipart: { enabled: true },
|
|
170
|
+
onBusinessPayload: async (payload) => persistIncomingPayload(payload),
|
|
171
|
+
onDuplicate: async (info) => traceDuplicate(info),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
这样当前台页面还活着时,SSE bridge 先进入 SW,`notification.show:'when-hidden'` 不弹系统通知但会触发业务落地;如果页面随后隐藏或已关闭,Web Push backup 到达 SW 后会命中同一个 key,只补通知,不重复调用 `onBusinessPayload`。
|
|
176
|
+
|
|
91
177
|
### Blob envelope
|
|
92
178
|
|
|
93
|
-
当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。
|
|
179
|
+
当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type?, messageId?, id?, dedupeKey? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。
|
|
94
180
|
|
|
95
181
|
### Generic multipart transport(2.1.0+)
|
|
96
182
|
|
package/dist/index.cjs
CHANGED
|
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
+
REI_AMSG_DELIVER_MESSAGE_TYPE: () => REI_AMSG_DELIVER_MESSAGE_TYPE,
|
|
22
23
|
REI_AMSG_POSTMESSAGE_TYPE: () => REI_AMSG_POSTMESSAGE_TYPE,
|
|
23
24
|
REI_SW_EVENT: () => REI_SW_EVENT,
|
|
24
25
|
REI_SW_MESSAGE_TYPE: () => REI_SW_MESSAGE_TYPE,
|
|
@@ -33,6 +34,10 @@ var REI_SW_MULTIPART_DONE_STORE = "multipart-done";
|
|
|
33
34
|
var REI_SW_MULTIPART_CHUNK_STORE = "multipart-chunk";
|
|
34
35
|
var REI_SW_DB_VERSION = 3;
|
|
35
36
|
var cachedDB = null;
|
|
37
|
+
var REI_AMSG_DEDUPE_DB_NAME = "rei_amsg_sw_dedupe_v1";
|
|
38
|
+
var REI_AMSG_DEDUPE_STORE = "delivery-dedupe";
|
|
39
|
+
var DEFAULT_DEDUPE_TTL_MS = 10 * 6e4;
|
|
40
|
+
var DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS = 6e4;
|
|
36
41
|
var REI_SW_SYNC_TAG = "rei-sw-flush-request-outbox";
|
|
37
42
|
var MULTIPART_MESSAGE_KIND = "_multipart";
|
|
38
43
|
var MULTIPART_ENCODING = "json-utf8-base64url";
|
|
@@ -46,6 +51,8 @@ var DEFAULT_MULTIPART_OPTIONS = Object.freeze({
|
|
|
46
51
|
var memoryMultipartPending = /* @__PURE__ */ new Map();
|
|
47
52
|
var memoryMultipartDone = /* @__PURE__ */ new Map();
|
|
48
53
|
var memoryMultipartChunks = /* @__PURE__ */ new Map();
|
|
54
|
+
var multipartLocks = /* @__PURE__ */ new Map();
|
|
55
|
+
var dedupeDbCache = /* @__PURE__ */ new Map();
|
|
49
56
|
var REI_AMSG_POSTMESSAGE_TYPE = "REI_AMSG_PUSH";
|
|
50
57
|
var REI_SW_EVENT = Object.freeze({
|
|
51
58
|
CONTENT_RECEIVED: "rei-amsg-content-received",
|
|
@@ -57,27 +64,39 @@ var REI_SW_EVENT = Object.freeze({
|
|
|
57
64
|
});
|
|
58
65
|
var REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
59
66
|
ENQUEUE_REQUEST: "REI_ENQUEUE_REQUEST",
|
|
67
|
+
DELIVER: "REI_AMSG_DELIVER",
|
|
60
68
|
FLUSH_QUEUE: "REI_FLUSH_QUEUE",
|
|
61
69
|
QUEUE_RESULT: "REI_QUEUE_RESULT"
|
|
62
70
|
});
|
|
71
|
+
var REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
|
|
63
72
|
function installReiSW(sw, opts = {}) {
|
|
64
73
|
const defaultIcon = opts.defaultIcon || "/icon-192x192.png";
|
|
65
74
|
const defaultBadge = opts.defaultBadge || "/badge-72x72.png";
|
|
66
75
|
const multipart = normalizeMultipartOptions(opts.multipart);
|
|
76
|
+
const dedupe = normalizeDedupeOptions(opts.dedupe);
|
|
67
77
|
let lastMultipartCleanupAt = 0;
|
|
78
|
+
let lastDedupeCleanupAt = 0;
|
|
79
|
+
const makeDeliveryContext = (source) => ({
|
|
80
|
+
defaultBadge,
|
|
81
|
+
defaultIcon,
|
|
82
|
+
dedupe,
|
|
83
|
+
multipart,
|
|
84
|
+
onDuplicate: opts.onDuplicate,
|
|
85
|
+
onBusinessPayload: opts.onBusinessPayload,
|
|
86
|
+
source,
|
|
87
|
+
getLastDedupeCleanupAt: () => lastDedupeCleanupAt,
|
|
88
|
+
setLastDedupeCleanupAt: (value) => {
|
|
89
|
+
lastDedupeCleanupAt = value;
|
|
90
|
+
},
|
|
91
|
+
getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
|
|
92
|
+
setLastMultipartCleanupAt: (value) => {
|
|
93
|
+
lastMultipartCleanupAt = value;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
68
96
|
sw.addEventListener("push", (event) => {
|
|
69
97
|
const payload = readPushPayload(event);
|
|
70
98
|
if (!payload) return;
|
|
71
|
-
event.waitUntil(handlePushPayload(sw, payload,
|
|
72
|
-
defaultBadge,
|
|
73
|
-
defaultIcon,
|
|
74
|
-
multipart,
|
|
75
|
-
onBusinessPayload: opts.onBusinessPayload,
|
|
76
|
-
getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
|
|
77
|
-
setLastMultipartCleanupAt: (value) => {
|
|
78
|
-
lastMultipartCleanupAt = value;
|
|
79
|
-
}
|
|
80
|
-
}));
|
|
99
|
+
event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext("webpush")));
|
|
81
100
|
});
|
|
82
101
|
sw.addEventListener("message", (event) => {
|
|
83
102
|
const message = event.data;
|
|
@@ -88,6 +107,10 @@ function installReiSW(sw, opts = {}) {
|
|
|
88
107
|
);
|
|
89
108
|
return;
|
|
90
109
|
}
|
|
110
|
+
if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) {
|
|
111
|
+
event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext()));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
91
114
|
if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) {
|
|
92
115
|
event.waitUntil(flushQueuedRequests(sw));
|
|
93
116
|
}
|
|
@@ -103,16 +126,48 @@ async function handlePushPayload(sw, payload, ctx) {
|
|
|
103
126
|
if (!ctx.multipart.enabled) return;
|
|
104
127
|
const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
|
|
105
128
|
if (!restoredPayload) return;
|
|
106
|
-
|
|
107
|
-
|
|
129
|
+
return handlePushPayload(sw, restoredPayload, ctx);
|
|
130
|
+
}
|
|
131
|
+
const claim = await claimDedupe(payload, ctx);
|
|
132
|
+
if (claim.duplicate) {
|
|
133
|
+
const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx);
|
|
134
|
+
claim.duplicateNotification = duplicateNotification;
|
|
135
|
+
await notifyDuplicate(payload, claim, ctx);
|
|
136
|
+
return { ...claim, duplicateNotification };
|
|
108
137
|
}
|
|
109
138
|
await dispatchBusinessPayload(sw, payload, {
|
|
110
139
|
defaultIcon: ctx.defaultIcon,
|
|
111
140
|
defaultBadge: ctx.defaultBadge,
|
|
112
141
|
onBusinessPayload: ctx.onBusinessPayload
|
|
142
|
+
}, async (intermediateResult) => {
|
|
143
|
+
await updateDedupeNotificationState(claim, ctx, intermediateResult);
|
|
113
144
|
});
|
|
145
|
+
return claim;
|
|
146
|
+
}
|
|
147
|
+
async function handleDeliverMessage(sw, event, message, ctx) {
|
|
148
|
+
let result = {};
|
|
149
|
+
try {
|
|
150
|
+
if (!Object.prototype.hasOwnProperty.call(message, "payload")) {
|
|
151
|
+
throw new Error("[rei-standard-amsg-sw] REI_AMSG_DELIVER requires payload");
|
|
152
|
+
}
|
|
153
|
+
const source = typeof message.source === "string" && message.source ? message.source : "message";
|
|
154
|
+
result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {};
|
|
155
|
+
respondToSender(event, {
|
|
156
|
+
ok: true,
|
|
157
|
+
duplicate: Boolean(result.duplicate),
|
|
158
|
+
key: result.key,
|
|
159
|
+
requestId: message.requestId
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
respondToSender(event, {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: error instanceof Error ? error.message : "Failed to deliver payload",
|
|
165
|
+
key: result && result.key,
|
|
166
|
+
requestId: message.requestId
|
|
167
|
+
});
|
|
168
|
+
}
|
|
114
169
|
}
|
|
115
|
-
async function dispatchBusinessPayload(sw, payload, defaults) {
|
|
170
|
+
async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) {
|
|
116
171
|
const eventName = resolveEventName(payload);
|
|
117
172
|
let clientList = [];
|
|
118
173
|
try {
|
|
@@ -122,40 +177,41 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
|
|
|
122
177
|
});
|
|
123
178
|
} catch (_matchError) {
|
|
124
179
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
shouldRenderNotification = !hasVisibleClient;
|
|
132
|
-
} else if (showOpt === false) {
|
|
133
|
-
shouldRenderNotification = false;
|
|
134
|
-
} else {
|
|
135
|
-
shouldRenderNotification = isNotificationKind(payload);
|
|
136
|
-
}
|
|
137
|
-
const work = [dispatchPushToClients(sw, eventName, payload, clientList)];
|
|
138
|
-
if (shouldRenderNotification) {
|
|
180
|
+
const notificationState = {
|
|
181
|
+
shouldRender: shouldRenderNotification(payload, clientList),
|
|
182
|
+
shown: false
|
|
183
|
+
};
|
|
184
|
+
const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
|
|
185
|
+
if (notificationState.shouldRender) {
|
|
139
186
|
const notification = createNotificationFromPayload(payload, defaults);
|
|
140
187
|
if (notification) {
|
|
141
|
-
|
|
142
|
-
sw.registration.showNotification(notification.title, notification.options)
|
|
188
|
+
notificationWork.push(
|
|
189
|
+
sw.registration.showNotification(notification.title, notification.options).then(() => {
|
|
190
|
+
notificationState.shown = true;
|
|
191
|
+
})
|
|
143
192
|
);
|
|
144
193
|
}
|
|
145
194
|
}
|
|
195
|
+
let businessWork = null;
|
|
146
196
|
if (typeof defaults.onBusinessPayload === "function") {
|
|
147
197
|
try {
|
|
148
198
|
const result = defaults.onBusinessPayload(payload);
|
|
149
|
-
if (result
|
|
150
|
-
|
|
199
|
+
if (result && typeof result.then === "function") {
|
|
200
|
+
businessWork = Promise.resolve(result).catch((error) => {
|
|
151
201
|
console.error("[rei-standard-amsg-sw] onBusinessPayload promise rejected:", error);
|
|
152
|
-
})
|
|
202
|
+
});
|
|
153
203
|
}
|
|
154
204
|
} catch (error) {
|
|
155
205
|
console.error("[rei-standard-amsg-sw] onBusinessPayload error:", error);
|
|
156
206
|
}
|
|
157
207
|
}
|
|
158
|
-
await Promise.all(
|
|
208
|
+
await Promise.all(notificationWork);
|
|
209
|
+
const settledResult = { eventName, notification: notificationState };
|
|
210
|
+
if (typeof onNotificationSettled === "function") {
|
|
211
|
+
await onNotificationSettled(settledResult);
|
|
212
|
+
}
|
|
213
|
+
if (businessWork) await businessWork;
|
|
214
|
+
return settledResult;
|
|
159
215
|
}
|
|
160
216
|
function resolveEventName(payload) {
|
|
161
217
|
const kind = payload && typeof payload === "object" ? payload.messageKind : void 0;
|
|
@@ -178,6 +234,19 @@ function isNotificationKind(payload) {
|
|
|
178
234
|
if (kind === void 0 || kind === null) return true;
|
|
179
235
|
return kind === import_amsg_shared.MESSAGE_KIND.CONTENT;
|
|
180
236
|
}
|
|
237
|
+
function shouldRenderNotification(payload, clientList) {
|
|
238
|
+
const showOpt = payload && payload.notification ? payload.notification.show : void 0;
|
|
239
|
+
if (showOpt === "always") {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (showOpt === "when-hidden") {
|
|
243
|
+
return !clientList.some((client) => client.visibilityState === "visible");
|
|
244
|
+
}
|
|
245
|
+
if (showOpt === false) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return isNotificationKind(payload);
|
|
249
|
+
}
|
|
181
250
|
async function dispatchPushToClients(sw, eventName, payload, preFetchedClientList = null) {
|
|
182
251
|
try {
|
|
183
252
|
const clientList = preFetchedClientList || await sw.clients.matchAll({
|
|
@@ -222,7 +291,7 @@ function createNotificationFromPayload(payload, defaults) {
|
|
|
222
291
|
};
|
|
223
292
|
}
|
|
224
293
|
const pushNotification = payload.notification && typeof payload.notification === "object" ? payload.notification : {};
|
|
225
|
-
const title = pushNotification.title || payload.title || payload.contactName || "New notification";
|
|
294
|
+
const title = pushNotification.title || payload.title || payload.contactName && `\u6765\u81EA ${payload.contactName}` || "New notification";
|
|
226
295
|
const body = pushNotification.body || payload.body || payload.message || "";
|
|
227
296
|
const data = pushNotification.data && typeof pushNotification.data === "object" ? { ...pushNotification.data } : payload.data && typeof payload.data === "object" ? { ...payload.data } : {};
|
|
228
297
|
if (data.payload == null) data.payload = payload;
|
|
@@ -237,7 +306,8 @@ function createNotificationFromPayload(payload, defaults) {
|
|
|
237
306
|
renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false),
|
|
238
307
|
requireInteraction: Boolean(
|
|
239
308
|
pushNotification.requireInteraction ?? payload.requireInteraction ?? false
|
|
240
|
-
)
|
|
309
|
+
),
|
|
310
|
+
silent: Boolean(pushNotification.silent ?? payload.silent ?? false)
|
|
241
311
|
}
|
|
242
312
|
};
|
|
243
313
|
}
|
|
@@ -257,15 +327,218 @@ function normalizeMultipartOptions(input) {
|
|
|
257
327
|
)
|
|
258
328
|
};
|
|
259
329
|
}
|
|
330
|
+
function normalizeDedupeOptions(input) {
|
|
331
|
+
const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
332
|
+
if (Object.prototype.hasOwnProperty.call(source, "storeName")) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"[rei-standard-amsg-sw] dedupe.storeName \u4E0D\u518D\u53EF\u914D\u7F6E\u3002\u6539 storeName \u4F1A\u89E6\u53D1 IndexedDB \u7248\u672C\u5347\u7EA7\uFF0C\u672C\u5305\u4E0D\u7EF4\u62A4 migration \u903B\u8F91\u3002\u9700\u8981\u9694\u79BB\u53BB\u91CD\u6570\u636E\u8BF7\u6539\u7528 dedupe.dbName\uFF08\u6BCF\u4E2A dbName \u662F\u72EC\u7ACB IDB \u5B9E\u4F8B\uFF09\u3002"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
enabled: source.enabled !== false,
|
|
339
|
+
ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_DEDUPE_TTL_MS),
|
|
340
|
+
cleanupIntervalMs: source.cleanupIntervalMs === 0 ? 0 : positiveIntegerOrDefault(
|
|
341
|
+
source.cleanupIntervalMs,
|
|
342
|
+
DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS
|
|
343
|
+
),
|
|
344
|
+
key: typeof source.key === "function" ? source.key : null,
|
|
345
|
+
dbName: typeof source.dbName === "string" && source.dbName.trim() ? source.dbName.trim() : REI_AMSG_DEDUPE_DB_NAME,
|
|
346
|
+
storeName: REI_AMSG_DEDUPE_STORE,
|
|
347
|
+
_memoryStore: /* @__PURE__ */ new Map()
|
|
348
|
+
};
|
|
349
|
+
}
|
|
260
350
|
function positiveIntegerOrDefault(value, fallback) {
|
|
261
351
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
262
352
|
}
|
|
353
|
+
async function claimDedupe(payload, ctx) {
|
|
354
|
+
if (!ctx.dedupe || ctx.dedupe.enabled === false) {
|
|
355
|
+
return { duplicate: false, key: void 0 };
|
|
356
|
+
}
|
|
357
|
+
const key = resolveDedupeKey(payload, ctx.dedupe);
|
|
358
|
+
if (!key) return { duplicate: false, key: void 0 };
|
|
359
|
+
await maybeCleanupDedupe(ctx);
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
const record = {
|
|
362
|
+
key,
|
|
363
|
+
firstSeenAt: now,
|
|
364
|
+
expiresAt: now + ctx.dedupe.ttlMs,
|
|
365
|
+
source: ctx.source || "unknown",
|
|
366
|
+
messageKind: getPayloadMessageKind(payload),
|
|
367
|
+
notificationShown: false,
|
|
368
|
+
notificationStatePending: true
|
|
369
|
+
};
|
|
370
|
+
if (await addDedupeRecord(ctx.dedupe, record)) {
|
|
371
|
+
return { duplicate: false, key, record };
|
|
372
|
+
}
|
|
373
|
+
const existing = await readDedupeRecord(ctx.dedupe, key);
|
|
374
|
+
if (existing && existing.expiresAt <= now) {
|
|
375
|
+
await deleteDedupeRecord(ctx.dedupe, key);
|
|
376
|
+
if (await addDedupeRecord(ctx.dedupe, record)) {
|
|
377
|
+
return { duplicate: false, key, record };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
duplicate: true,
|
|
382
|
+
key,
|
|
383
|
+
record,
|
|
384
|
+
existing: existing || null
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function updateDedupeNotificationState(claim, ctx, dispatchResult) {
|
|
388
|
+
if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return;
|
|
389
|
+
if (!dispatchResult || !dispatchResult.notification) return;
|
|
390
|
+
const notification = dispatchResult.notification;
|
|
391
|
+
const next = {
|
|
392
|
+
...claim.record,
|
|
393
|
+
notificationShown: notification.shown === true,
|
|
394
|
+
notificationStatePending: false
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
await putDedupeRecord(ctx.dedupe, next);
|
|
398
|
+
claim.record = next;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error("[rei-standard-amsg-sw] dedupe notification state update failed:", error);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async function maybeShowDuplicateNotification(sw, payload, claim, ctx) {
|
|
404
|
+
const existing = claim && claim.existing ? claim.existing : null;
|
|
405
|
+
if (!existing || existing.notificationShown === true) {
|
|
406
|
+
return { shown: false, reason: existing ? "already-shown" : "no-existing-record" };
|
|
407
|
+
}
|
|
408
|
+
if (existing.notificationStatePending === true) {
|
|
409
|
+
return { shown: false, reason: "first-delivery-pending" };
|
|
410
|
+
}
|
|
411
|
+
let clientList = [];
|
|
412
|
+
try {
|
|
413
|
+
clientList = await sw.clients.matchAll({
|
|
414
|
+
type: "window",
|
|
415
|
+
includeUncontrolled: true
|
|
416
|
+
});
|
|
417
|
+
} catch (_matchError) {
|
|
418
|
+
}
|
|
419
|
+
if (!shouldRenderNotification(payload, clientList)) {
|
|
420
|
+
return { shown: false, reason: "policy-suppressed" };
|
|
421
|
+
}
|
|
422
|
+
const notification = createNotificationFromPayload(payload, {
|
|
423
|
+
defaultIcon: ctx.defaultIcon,
|
|
424
|
+
defaultBadge: ctx.defaultBadge
|
|
425
|
+
});
|
|
426
|
+
if (!notification) {
|
|
427
|
+
return { shown: false, reason: "no-notification" };
|
|
428
|
+
}
|
|
429
|
+
await sw.registration.showNotification(notification.title, notification.options);
|
|
430
|
+
const next = {
|
|
431
|
+
...existing,
|
|
432
|
+
notificationShown: true,
|
|
433
|
+
notificationStatePending: false
|
|
434
|
+
};
|
|
435
|
+
await putDedupeRecord(ctx.dedupe, next);
|
|
436
|
+
return { shown: true, reason: "shown-from-duplicate" };
|
|
437
|
+
}
|
|
438
|
+
function resolveDedupeKey(payload, dedupe) {
|
|
439
|
+
if (typeof dedupe.key === "function") {
|
|
440
|
+
try {
|
|
441
|
+
const custom = dedupe.key(payload);
|
|
442
|
+
return typeof custom === "string" && custom.trim() ? custom.trim() : void 0;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error("[rei-standard-amsg-sw] dedupe.key error:", error);
|
|
445
|
+
return void 0;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
449
|
+
for (const field of ["messageId", "id", "dedupeKey"]) {
|
|
450
|
+
const value = payload[field];
|
|
451
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
452
|
+
}
|
|
453
|
+
return void 0;
|
|
454
|
+
}
|
|
455
|
+
function getPayloadMessageKind(payload) {
|
|
456
|
+
return payload && typeof payload === "object" && typeof payload.messageKind === "string" ? payload.messageKind : void 0;
|
|
457
|
+
}
|
|
458
|
+
async function notifyDuplicate(payload, claim, ctx) {
|
|
459
|
+
if (typeof ctx.onDuplicate !== "function") return;
|
|
460
|
+
const existing = claim.existing || {};
|
|
461
|
+
const info = {
|
|
462
|
+
key: claim.key,
|
|
463
|
+
source: ctx.source || "unknown",
|
|
464
|
+
messageKind: getPayloadMessageKind(payload),
|
|
465
|
+
firstSeenAt: existing.firstSeenAt,
|
|
466
|
+
existingSource: existing.source,
|
|
467
|
+
existingMessageKind: existing.messageKind,
|
|
468
|
+
existingNotificationShown: existing.notificationShown === true,
|
|
469
|
+
duplicateNotificationShown: claim.duplicateNotification && claim.duplicateNotification.shown === true
|
|
470
|
+
};
|
|
471
|
+
try {
|
|
472
|
+
await ctx.onDuplicate(info);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error("[rei-standard-amsg-sw] onDuplicate error:", error);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function maybeCleanupDedupe(ctx) {
|
|
478
|
+
if (!ctx.dedupe || ctx.dedupe.enabled === false || ctx.dedupe.cleanupIntervalMs === 0) return;
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
const last = ctx.getLastDedupeCleanupAt ? ctx.getLastDedupeCleanupAt() : 0;
|
|
481
|
+
if (last && now - last < ctx.dedupe.cleanupIntervalMs) return;
|
|
482
|
+
if (ctx.setLastDedupeCleanupAt) ctx.setLastDedupeCleanupAt(now);
|
|
483
|
+
try {
|
|
484
|
+
await cleanupDedupeStore(ctx.dedupe, now);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error("[rei-standard-amsg-sw] dedupe cleanup failed:", error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function cleanupDedupeStore(dedupe, now) {
|
|
490
|
+
if (!hasIndexedDB()) {
|
|
491
|
+
const store = memoryDedupeStoreFor(dedupe);
|
|
492
|
+
for (const [key, record] of store.entries()) {
|
|
493
|
+
if (record.expiresAt <= now) store.delete(key);
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
await withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
|
|
498
|
+
const index = store.index("expiresAt");
|
|
499
|
+
const range = IDBKeyRange.upperBound(now);
|
|
500
|
+
let failed = false;
|
|
501
|
+
const request = index.openCursor(range);
|
|
502
|
+
request.onsuccess = () => {
|
|
503
|
+
if (failed) return;
|
|
504
|
+
const cursor = request.result;
|
|
505
|
+
if (!cursor) {
|
|
506
|
+
resolve(void 0);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const deleteRequest = cursor.delete();
|
|
510
|
+
deleteRequest.onsuccess = () => {
|
|
511
|
+
if (failed) return;
|
|
512
|
+
cursor.continue();
|
|
513
|
+
};
|
|
514
|
+
deleteRequest.onerror = () => {
|
|
515
|
+
if (!failed) {
|
|
516
|
+
failed = true;
|
|
517
|
+
reject(deleteRequest.error || new Error("Failed to delete expired dedupe record"));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
request.onerror = () => reject(request.error || new Error("Failed to scan expired dedupe records"));
|
|
522
|
+
});
|
|
523
|
+
}
|
|
263
524
|
function isMultipartPush(payload) {
|
|
264
525
|
return !!payload && typeof payload === "object" && payload.messageKind === MULTIPART_MESSAGE_KIND && payload.multipart && typeof payload.multipart === "object" && typeof payload.chunk === "string";
|
|
265
526
|
}
|
|
266
527
|
async function acceptMultipartChunk(sw, payload, options) {
|
|
267
528
|
const normalized = normalizeMultipartChunk(payload, options);
|
|
268
529
|
if (!normalized) return null;
|
|
530
|
+
const previous = multipartLocks.get(normalized.id) || Promise.resolve();
|
|
531
|
+
const current = previous.catch(() => void 0).then(() => acceptMultipartChunkInternal(sw, normalized, options));
|
|
532
|
+
multipartLocks.set(normalized.id, current);
|
|
533
|
+
try {
|
|
534
|
+
return await current;
|
|
535
|
+
} finally {
|
|
536
|
+
if (multipartLocks.get(normalized.id) === current) {
|
|
537
|
+
multipartLocks.delete(normalized.id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function acceptMultipartChunkInternal(sw, normalized, options) {
|
|
269
542
|
if (normalized.expiresAt <= Date.now()) {
|
|
270
543
|
await dispatchMultipartExpired(sw, {
|
|
271
544
|
id: normalized.id,
|
|
@@ -564,6 +837,69 @@ function respondToSender(event, message) {
|
|
|
564
837
|
source.postMessage(message);
|
|
565
838
|
}
|
|
566
839
|
}
|
|
840
|
+
async function addDedupeRecord(dedupe, record) {
|
|
841
|
+
if (!hasIndexedDB()) {
|
|
842
|
+
const store = memoryDedupeStoreFor(dedupe);
|
|
843
|
+
if (store.has(record.key)) return false;
|
|
844
|
+
store.set(record.key, cloneRecord(record));
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
|
|
848
|
+
let settled = false;
|
|
849
|
+
const request = store.add(record);
|
|
850
|
+
request.onsuccess = () => {
|
|
851
|
+
settled = true;
|
|
852
|
+
resolve(true);
|
|
853
|
+
};
|
|
854
|
+
request.onerror = (event) => {
|
|
855
|
+
settled = true;
|
|
856
|
+
if (request.error && request.error.name === "ConstraintError") {
|
|
857
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
858
|
+
resolve(false);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
reject(request.error || new Error("Failed to add dedupe record"));
|
|
862
|
+
};
|
|
863
|
+
store.transaction.onerror = () => {
|
|
864
|
+
if (!settled) reject(store.transaction.error || new Error("Dedupe transaction failed"));
|
|
865
|
+
};
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
function readDedupeRecord(dedupe, key) {
|
|
869
|
+
if (!hasIndexedDB()) {
|
|
870
|
+
return Promise.resolve(cloneRecord(memoryDedupeStoreFor(dedupe).get(key) || null));
|
|
871
|
+
}
|
|
872
|
+
return withDedupeStore(dedupe, "readonly", (store, resolve, reject) => {
|
|
873
|
+
const request = store.get(key);
|
|
874
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
875
|
+
request.onerror = () => reject(request.error || new Error("Failed to read dedupe record"));
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function putDedupeRecord(dedupe, record) {
|
|
879
|
+
if (!record || typeof record.key !== "string" || !record.key) {
|
|
880
|
+
return Promise.resolve();
|
|
881
|
+
}
|
|
882
|
+
if (!hasIndexedDB()) {
|
|
883
|
+
memoryDedupeStoreFor(dedupe).set(record.key, cloneRecord(record));
|
|
884
|
+
return Promise.resolve();
|
|
885
|
+
}
|
|
886
|
+
return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
|
|
887
|
+
const request = store.put(record);
|
|
888
|
+
request.onsuccess = () => resolve(void 0);
|
|
889
|
+
request.onerror = () => reject(request.error || new Error("Failed to put dedupe record"));
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
function deleteDedupeRecord(dedupe, key) {
|
|
893
|
+
if (!hasIndexedDB()) {
|
|
894
|
+
memoryDedupeStoreFor(dedupe).delete(key);
|
|
895
|
+
return Promise.resolve();
|
|
896
|
+
}
|
|
897
|
+
return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
|
|
898
|
+
const request = store.delete(key);
|
|
899
|
+
request.onsuccess = () => resolve(void 0);
|
|
900
|
+
request.onerror = () => reject(request.error || new Error("Failed to delete dedupe record"));
|
|
901
|
+
});
|
|
902
|
+
}
|
|
567
903
|
function readMultipartPending(id) {
|
|
568
904
|
return readStoreRecord(REI_SW_MULTIPART_STORE, id);
|
|
569
905
|
}
|
|
@@ -672,9 +1008,22 @@ async function withDatabaseStore(storeName, mode, handler) {
|
|
|
672
1008
|
Promise.resolve(handler(store, resolve, reject)).catch(reject);
|
|
673
1009
|
});
|
|
674
1010
|
}
|
|
1011
|
+
async function withDedupeStore(dedupe, mode, handler) {
|
|
1012
|
+
const db = await openDedupeDatabase(dedupe);
|
|
1013
|
+
return new Promise((resolve, reject) => {
|
|
1014
|
+
const transaction = db.transaction(dedupe.storeName, mode);
|
|
1015
|
+
const store = transaction.objectStore(dedupe.storeName);
|
|
1016
|
+
transaction.onerror = () => reject(transaction.error || new Error("Dedupe transaction failed"));
|
|
1017
|
+
Promise.resolve(handler(store, resolve, reject)).catch(reject);
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
675
1020
|
function hasIndexedDB() {
|
|
676
1021
|
return typeof indexedDB !== "undefined" && indexedDB && typeof indexedDB.open === "function";
|
|
677
1022
|
}
|
|
1023
|
+
function memoryDedupeStoreFor(dedupe) {
|
|
1024
|
+
if (!dedupe._memoryStore) dedupe._memoryStore = /* @__PURE__ */ new Map();
|
|
1025
|
+
return dedupe._memoryStore;
|
|
1026
|
+
}
|
|
678
1027
|
function memoryStoreFor(storeName) {
|
|
679
1028
|
if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
|
|
680
1029
|
if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
|
|
@@ -685,6 +1034,31 @@ function cloneRecord(record) {
|
|
|
685
1034
|
if (record == null) return null;
|
|
686
1035
|
return JSON.parse(JSON.stringify(record));
|
|
687
1036
|
}
|
|
1037
|
+
function openDedupeDatabase(dedupe) {
|
|
1038
|
+
const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`;
|
|
1039
|
+
const cached = dedupeDbCache.get(cacheKey);
|
|
1040
|
+
if (cached) return Promise.resolve(cached);
|
|
1041
|
+
return new Promise((resolve, reject) => {
|
|
1042
|
+
const request = indexedDB.open(dedupe.dbName, 1);
|
|
1043
|
+
request.onupgradeneeded = () => {
|
|
1044
|
+
const db = request.result;
|
|
1045
|
+
const store = db.objectStoreNames.contains(dedupe.storeName) ? request.transaction.objectStore(dedupe.storeName) : db.createObjectStore(dedupe.storeName, { keyPath: "key" });
|
|
1046
|
+
if (store && !store.indexNames.contains("expiresAt")) {
|
|
1047
|
+
store.createIndex("expiresAt", "expiresAt", { unique: false });
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
request.onsuccess = () => {
|
|
1051
|
+
const db = request.result;
|
|
1052
|
+
dedupeDbCache.set(cacheKey, db);
|
|
1053
|
+
db.onversionchange = () => {
|
|
1054
|
+
db.close();
|
|
1055
|
+
dedupeDbCache.delete(cacheKey);
|
|
1056
|
+
};
|
|
1057
|
+
resolve(db);
|
|
1058
|
+
};
|
|
1059
|
+
request.onerror = () => reject(request.error || new Error("Failed to open dedupe database"));
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
688
1062
|
function openQueueDatabase() {
|
|
689
1063
|
if (cachedDB) return Promise.resolve(cachedDB);
|
|
690
1064
|
return new Promise((resolve, reject) => {
|