@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 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
- await handlePushPayload(sw, restoredPayload, ctx);
107
- return;
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
- let shouldRenderNotification = false;
126
- const showOpt = payload && payload.notification ? payload.notification.show : void 0;
127
- if (showOpt === "always") {
128
- shouldRenderNotification = true;
129
- } else if (showOpt === "when-hidden") {
130
- const hasVisibleClient = clientList.some((client) => client.visibilityState === "visible");
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
- work.push(
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 instanceof Promise) {
150
- work.push(result.catch((error) => {
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(work);
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) => {