@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/dist/index.d.ts CHANGED
@@ -53,6 +53,10 @@ const REI_SW_MULTIPART_DONE_STORE = 'multipart-done';
53
53
  const REI_SW_MULTIPART_CHUNK_STORE = 'multipart-chunk';
54
54
  const REI_SW_DB_VERSION = 3;
55
55
  let cachedDB = null;
56
+ const REI_AMSG_DEDUPE_DB_NAME = 'rei_amsg_sw_dedupe_v1';
57
+ const REI_AMSG_DEDUPE_STORE = 'delivery-dedupe';
58
+ const DEFAULT_DEDUPE_TTL_MS = 10 * 60_000;
59
+ const DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS = 60_000;
56
60
  const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox';
57
61
  const MULTIPART_MESSAGE_KIND = '_multipart';
58
62
  const MULTIPART_ENCODING = 'json-utf8-base64url';
@@ -66,6 +70,8 @@ const DEFAULT_MULTIPART_OPTIONS = Object.freeze({
66
70
  const memoryMultipartPending = new Map();
67
71
  const memoryMultipartDone = new Map();
68
72
  const memoryMultipartChunks = new Map();
73
+ const multipartLocks = new Map();
74
+ const dedupeDbCache = new Map();
69
75
 
70
76
  /**
71
77
  * Wire-level message type for SW → client postMessage envelopes.
@@ -95,10 +101,13 @@ const REI_SW_EVENT = Object.freeze({
95
101
 
96
102
  const REI_SW_MESSAGE_TYPE = Object.freeze({
97
103
  ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST',
104
+ DELIVER: 'REI_AMSG_DELIVER',
98
105
  FLUSH_QUEUE: 'REI_FLUSH_QUEUE',
99
106
  QUEUE_RESULT: 'REI_QUEUE_RESULT'
100
107
  });
101
108
 
109
+ const REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
110
+
102
111
  /**
103
112
  * @typedef {Object} ReiSWOptions
104
113
  * @property {string} [defaultIcon] - Fallback notification icon URL.
@@ -109,7 +118,14 @@ const REI_SW_MESSAGE_TYPE = Object.freeze({
109
118
  * @property {number} [multipart.maxTotalBytes=256000]
110
119
  * @property {number} [multipart.maxChunks=128]
111
120
  * @property {number} [multipart.cleanupIntervalMs=900000]
121
+ * @property {Object} [dedupe]
122
+ * @property {boolean} [dedupe.enabled=true]
123
+ * @property {number} [dedupe.ttlMs=600000]
124
+ * @property {number} [dedupe.cleanupIntervalMs=60000]
125
+ * @property {(payload: any) => string | undefined} [dedupe.key]
126
+ * @property {string} [dedupe.dbName='rei_amsg_sw_dedupe_v1'] - 隔离去重数据用。每个 dbName 对应一个独立的 IndexedDB instance,互不影响。`dedupe.storeName` 不再可配(传了会抛错);本包不维护跨 dbName 的迁移逻辑。
112
127
  * @property {(payload: any) => void | Promise<void>} [onBusinessPayload]
128
+ * @property {(info: { key: string, source: string, messageKind?: string, firstSeenAt?: number, existingSource?: string, existingMessageKind?: string, existingNotificationShown?: boolean, duplicateNotificationShown?: boolean }) => void | Promise<void>} [onDuplicate]
113
129
  */
114
130
 
115
131
  /**
@@ -122,20 +138,28 @@ function installReiSW(sw, opts = {}) {
122
138
  const defaultIcon = opts.defaultIcon || '/icon-192x192.png';
123
139
  const defaultBadge = opts.defaultBadge || '/badge-72x72.png';
124
140
  const multipart = normalizeMultipartOptions(opts.multipart);
141
+ const dedupe = normalizeDedupeOptions(opts.dedupe);
125
142
  let lastMultipartCleanupAt = 0;
143
+ let lastDedupeCleanupAt = 0;
144
+ const makeDeliveryContext = (source) => ({
145
+ defaultBadge,
146
+ defaultIcon,
147
+ dedupe,
148
+ multipart,
149
+ onDuplicate: opts.onDuplicate,
150
+ onBusinessPayload: opts.onBusinessPayload,
151
+ source,
152
+ getLastDedupeCleanupAt: () => lastDedupeCleanupAt,
153
+ setLastDedupeCleanupAt: (value) => { lastDedupeCleanupAt = value; },
154
+ getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
155
+ setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; },
156
+ });
126
157
 
127
158
  sw.addEventListener('push', (event) => {
128
159
  const payload = readPushPayload(event);
129
160
  if (!payload) return;
130
161
 
131
- event.waitUntil(handlePushPayload(sw, payload, {
132
- defaultBadge,
133
- defaultIcon,
134
- multipart,
135
- onBusinessPayload: opts.onBusinessPayload,
136
- getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
137
- setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; },
138
- }));
162
+ event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext('webpush')));
139
163
  });
140
164
 
141
165
  sw.addEventListener('message', (event) => {
@@ -149,6 +173,11 @@ function installReiSW(sw, opts = {}) {
149
173
  return;
150
174
  }
151
175
 
176
+ if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) {
177
+ event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext()));
178
+ return;
179
+ }
180
+
152
181
  if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) {
153
182
  event.waitUntil(flushQueuedRequests(sw));
154
183
  }
@@ -167,18 +196,58 @@ async function handlePushPayload(sw, payload, ctx) {
167
196
  if (!ctx.multipart.enabled) return;
168
197
  const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
169
198
  if (!restoredPayload) return;
170
- await handlePushPayload(sw, restoredPayload, ctx);
171
- return;
199
+ return handlePushPayload(sw, restoredPayload, ctx);
200
+ }
201
+
202
+ const claim = await claimDedupe(payload, ctx);
203
+ if (claim.duplicate) {
204
+ const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx);
205
+ claim.duplicateNotification = duplicateNotification;
206
+ await notifyDuplicate(payload, claim, ctx);
207
+ return { ...claim, duplicateNotification };
172
208
  }
173
209
 
174
210
  await dispatchBusinessPayload(sw, payload, {
175
211
  defaultIcon: ctx.defaultIcon,
176
212
  defaultBadge: ctx.defaultBadge,
177
213
  onBusinessPayload: ctx.onBusinessPayload,
214
+ }, async (intermediateResult) => {
215
+ // Settle the dedupe pending flag as soon as the notification policy
216
+ // is decided (dispatch + showNotification done) — do NOT wait for
217
+ // onBusinessPayload. A backup arriving mid-business would otherwise
218
+ // hit `notificationStatePending` and skip the repair path.
219
+ await updateDedupeNotificationState(claim, ctx, intermediateResult);
178
220
  });
221
+ return claim;
179
222
  }
180
223
 
181
- async function dispatchBusinessPayload(sw, payload, defaults) {
224
+ async function handleDeliverMessage(sw, event, message, ctx) {
225
+ let result = {};
226
+ try {
227
+ if (!Object.prototype.hasOwnProperty.call(message, 'payload')) {
228
+ throw new Error('[rei-standard-amsg-sw] REI_AMSG_DELIVER requires payload');
229
+ }
230
+ const source = typeof message.source === 'string' && message.source
231
+ ? message.source
232
+ : 'message';
233
+ result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {};
234
+ respondToSender(event, {
235
+ ok: true,
236
+ duplicate: Boolean(result.duplicate),
237
+ key: result.key,
238
+ requestId: message.requestId,
239
+ });
240
+ } catch (error) {
241
+ respondToSender(event, {
242
+ ok: false,
243
+ error: error instanceof Error ? error.message : 'Failed to deliver payload',
244
+ key: result && result.key,
245
+ requestId: message.requestId,
246
+ });
247
+ }
248
+ }
249
+
250
+ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) {
182
251
  const eventName = resolveEventName(payload);
183
252
 
184
253
  let clientList = [];
@@ -190,47 +259,56 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
190
259
  } catch (_matchError) {
191
260
  // Ignored
192
261
  }
193
-
194
- let shouldRenderNotification = false;
195
- const showOpt = payload && payload.notification ? payload.notification.show : undefined;
196
262
 
197
- if (showOpt === 'always') {
198
- shouldRenderNotification = true;
199
- } else if (showOpt === 'when-hidden') {
200
- const hasVisibleClient = clientList.some(client => client.visibilityState === 'visible');
201
- shouldRenderNotification = !hasVisibleClient;
202
- } else if (showOpt === false) {
203
- shouldRenderNotification = false;
204
- } else {
205
- shouldRenderNotification = isNotificationKind(payload);
206
- }
263
+ const notificationState = {
264
+ shouldRender: shouldRenderNotification(payload, clientList),
265
+ shown: false,
266
+ };
207
267
 
208
268
  /** @type {Array<Promise<unknown>>} */
209
- const work = [dispatchPushToClients(sw, eventName, payload, clientList)];
269
+ const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
210
270
 
211
- if (shouldRenderNotification) {
271
+ if (notificationState.shouldRender) {
212
272
  const notification = createNotificationFromPayload(payload, defaults);
213
273
  if (notification) {
214
- work.push(
274
+ notificationWork.push(
215
275
  sw.registration.showNotification(notification.title, notification.options)
276
+ .then(() => {
277
+ notificationState.shown = true;
278
+ })
216
279
  );
217
280
  }
218
281
  }
219
282
 
283
+ // Kick the user's business callback off in parallel with notification
284
+ // work, but do NOT block notification-state settlement on it. A slow
285
+ // onBusinessPayload would otherwise keep `notificationStatePending`
286
+ // set, and a Web Push backup arriving in that window would be swallowed
287
+ // as 'first-delivery-pending' with no chance to repair a missed
288
+ // notification. The overall waitUntil chain still awaits the business
289
+ // callback below so the SW does not get killed mid-flight.
290
+ let businessWork = null;
220
291
  if (typeof defaults.onBusinessPayload === 'function') {
221
292
  try {
222
293
  const result = defaults.onBusinessPayload(payload);
223
- if (result instanceof Promise) {
224
- work.push(result.catch(error => {
294
+ if (result && typeof result.then === 'function') {
295
+ businessWork = Promise.resolve(result).catch(error => {
225
296
  console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error);
226
- }));
297
+ });
227
298
  }
228
299
  } catch (error) {
229
300
  console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error);
230
301
  }
231
302
  }
232
303
 
233
- await Promise.all(work);
304
+ await Promise.all(notificationWork);
305
+ const settledResult = { eventName, notification: notificationState };
306
+ if (typeof onNotificationSettled === 'function') {
307
+ await onNotificationSettled(settledResult);
308
+ }
309
+ if (businessWork) await businessWork;
310
+
311
+ return settledResult;
234
312
  }
235
313
 
236
314
  /**
@@ -262,7 +340,6 @@ function resolveEventName(payload) {
262
340
  * `messageKind: 'content'` renders a notification; everything else
263
341
  * (`reasoning`, `tool_request`, `error`) is dispatched silently so
264
342
  * apps can render them in-app.
265
- *
266
343
  * Legacy payloads with no `messageKind` field still render a
267
344
  * notification — that's the 2.0.x back-compat path.
268
345
  *
@@ -276,6 +353,21 @@ function isNotificationKind(payload) {
276
353
  return kind === MESSAGE_KIND.CONTENT;
277
354
  }
278
355
 
356
+ function shouldRenderNotification(payload, clientList) {
357
+ const showOpt = payload && payload.notification ? payload.notification.show : undefined;
358
+
359
+ if (showOpt === 'always') {
360
+ return true;
361
+ }
362
+ if (showOpt === 'when-hidden') {
363
+ return !clientList.some(client => client.visibilityState === 'visible');
364
+ }
365
+ if (showOpt === false) {
366
+ return false;
367
+ }
368
+ return isNotificationKind(payload);
369
+ }
370
+
279
371
  /**
280
372
  * Broadcast a parsed push payload to every controlled client. Failures
281
373
  * on individual `postMessage` calls are swallowed — one offline tab
@@ -345,7 +437,7 @@ function createNotificationFromPayload(payload, defaults) {
345
437
  const title =
346
438
  pushNotification.title ||
347
439
  payload.title ||
348
- payload.contactName ||
440
+ (payload.contactName && `来自 ${payload.contactName}`) ||
349
441
  'New notification';
350
442
  const body = pushNotification.body || payload.body || payload.message || '';
351
443
  const data = pushNotification.data && typeof pushNotification.data === 'object'
@@ -366,7 +458,8 @@ function createNotificationFromPayload(payload, defaults) {
366
458
  renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false),
367
459
  requireInteraction: Boolean(
368
460
  pushNotification.requireInteraction ?? payload.requireInteraction ?? false
369
- )
461
+ ),
462
+ silent: Boolean(pushNotification.silent ?? payload.silent ?? false)
370
463
  }
371
464
  };
372
465
  }
@@ -390,10 +483,240 @@ function normalizeMultipartOptions(input) {
390
483
  };
391
484
  }
392
485
 
486
+ function normalizeDedupeOptions(input) {
487
+ const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
488
+
489
+ // storeName 不再可配。同 dbName 下 storeName 一变就要做 IDB 版本升级,
490
+ // 暴露这个配置点的收益(一个内部 store 名字)远小于让用户踩 IDB upgrade
491
+ // 坑的代价。隔离用 dbName —— 每个 dbName 是独立 IndexedDB instance。
492
+ if (Object.prototype.hasOwnProperty.call(source, 'storeName')) {
493
+ throw new Error(
494
+ '[rei-standard-amsg-sw] dedupe.storeName 不再可配置。改 storeName 会触发 IndexedDB 版本升级,'
495
+ + '本包不维护 migration 逻辑。需要隔离去重数据请改用 dedupe.dbName(每个 dbName 是独立 IDB 实例)。'
496
+ );
497
+ }
498
+
499
+ return {
500
+ enabled: source.enabled !== false,
501
+ ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_DEDUPE_TTL_MS),
502
+ cleanupIntervalMs: source.cleanupIntervalMs === 0
503
+ ? 0
504
+ : positiveIntegerOrDefault(
505
+ source.cleanupIntervalMs,
506
+ DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS
507
+ ),
508
+ key: typeof source.key === 'function' ? source.key : null,
509
+ dbName: typeof source.dbName === 'string' && source.dbName.trim()
510
+ ? source.dbName.trim()
511
+ : REI_AMSG_DEDUPE_DB_NAME,
512
+ storeName: REI_AMSG_DEDUPE_STORE,
513
+ _memoryStore: new Map(),
514
+ };
515
+ }
516
+
393
517
  function positiveIntegerOrDefault(value, fallback) {
394
518
  return Number.isInteger(value) && value > 0 ? value : fallback;
395
519
  }
396
520
 
521
+ async function claimDedupe(payload, ctx) {
522
+ if (!ctx.dedupe || ctx.dedupe.enabled === false) {
523
+ return { duplicate: false, key: undefined };
524
+ }
525
+
526
+ const key = resolveDedupeKey(payload, ctx.dedupe);
527
+ if (!key) return { duplicate: false, key: undefined };
528
+
529
+ await maybeCleanupDedupe(ctx);
530
+
531
+ const now = Date.now();
532
+ const record = {
533
+ key,
534
+ firstSeenAt: now,
535
+ expiresAt: now + ctx.dedupe.ttlMs,
536
+ source: ctx.source || 'unknown',
537
+ messageKind: getPayloadMessageKind(payload),
538
+ notificationShown: false,
539
+ notificationStatePending: true,
540
+ };
541
+
542
+ if (await addDedupeRecord(ctx.dedupe, record)) {
543
+ return { duplicate: false, key, record };
544
+ }
545
+
546
+ const existing = await readDedupeRecord(ctx.dedupe, key);
547
+ if (existing && existing.expiresAt <= now) {
548
+ await deleteDedupeRecord(ctx.dedupe, key);
549
+ if (await addDedupeRecord(ctx.dedupe, record)) {
550
+ return { duplicate: false, key, record };
551
+ }
552
+ }
553
+
554
+ return {
555
+ duplicate: true,
556
+ key,
557
+ record,
558
+ existing: existing || null,
559
+ };
560
+ }
561
+
562
+ async function updateDedupeNotificationState(claim, ctx, dispatchResult) {
563
+ if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return;
564
+ if (!dispatchResult || !dispatchResult.notification) return;
565
+
566
+ const notification = dispatchResult.notification;
567
+ const next = {
568
+ ...claim.record,
569
+ notificationShown: notification.shown === true,
570
+ notificationStatePending: false,
571
+ };
572
+
573
+ try {
574
+ await putDedupeRecord(ctx.dedupe, next);
575
+ claim.record = next;
576
+ } catch (error) {
577
+ console.error('[rei-standard-amsg-sw] dedupe notification state update failed:', error);
578
+ }
579
+ }
580
+
581
+ async function maybeShowDuplicateNotification(sw, payload, claim, ctx) {
582
+ const existing = claim && claim.existing ? claim.existing : null;
583
+ if (!existing || existing.notificationShown === true) {
584
+ return { shown: false, reason: existing ? 'already-shown' : 'no-existing-record' };
585
+ }
586
+ if (existing.notificationStatePending === true) {
587
+ return { shown: false, reason: 'first-delivery-pending' };
588
+ }
589
+
590
+ let clientList = [];
591
+ try {
592
+ clientList = await sw.clients.matchAll({
593
+ type: 'window',
594
+ includeUncontrolled: true
595
+ });
596
+ } catch (_matchError) {
597
+ // Ignored
598
+ }
599
+
600
+ if (!shouldRenderNotification(payload, clientList)) {
601
+ return { shown: false, reason: 'policy-suppressed' };
602
+ }
603
+
604
+ const notification = createNotificationFromPayload(payload, {
605
+ defaultIcon: ctx.defaultIcon,
606
+ defaultBadge: ctx.defaultBadge,
607
+ });
608
+ if (!notification) {
609
+ return { shown: false, reason: 'no-notification' };
610
+ }
611
+
612
+ await sw.registration.showNotification(notification.title, notification.options);
613
+
614
+ const next = {
615
+ ...existing,
616
+ notificationShown: true,
617
+ notificationStatePending: false,
618
+ };
619
+ await putDedupeRecord(ctx.dedupe, next);
620
+
621
+ return { shown: true, reason: 'shown-from-duplicate' };
622
+ }
623
+
624
+ function resolveDedupeKey(payload, dedupe) {
625
+ if (typeof dedupe.key === 'function') {
626
+ try {
627
+ const custom = dedupe.key(payload);
628
+ return typeof custom === 'string' && custom.trim() ? custom.trim() : undefined;
629
+ } catch (error) {
630
+ console.error('[rei-standard-amsg-sw] dedupe.key error:', error);
631
+ return undefined;
632
+ }
633
+ }
634
+
635
+ if (!payload || typeof payload !== 'object') return undefined;
636
+ for (const field of ['messageId', 'id', 'dedupeKey']) {
637
+ const value = payload[field];
638
+ if (typeof value === 'string' && value.trim()) return value.trim();
639
+ }
640
+ return undefined;
641
+ }
642
+
643
+ function getPayloadMessageKind(payload) {
644
+ return payload && typeof payload === 'object' && typeof payload.messageKind === 'string'
645
+ ? payload.messageKind
646
+ : undefined;
647
+ }
648
+
649
+ async function notifyDuplicate(payload, claim, ctx) {
650
+ if (typeof ctx.onDuplicate !== 'function') return;
651
+ const existing = claim.existing || {};
652
+ const info = {
653
+ key: claim.key,
654
+ source: ctx.source || 'unknown',
655
+ messageKind: getPayloadMessageKind(payload),
656
+ firstSeenAt: existing.firstSeenAt,
657
+ existingSource: existing.source,
658
+ existingMessageKind: existing.messageKind,
659
+ existingNotificationShown: existing.notificationShown === true,
660
+ duplicateNotificationShown: claim.duplicateNotification && claim.duplicateNotification.shown === true,
661
+ };
662
+ try {
663
+ await ctx.onDuplicate(info);
664
+ } catch (error) {
665
+ console.error('[rei-standard-amsg-sw] onDuplicate error:', error);
666
+ }
667
+ }
668
+
669
+ async function maybeCleanupDedupe(ctx) {
670
+ if (!ctx.dedupe || ctx.dedupe.enabled === false || ctx.dedupe.cleanupIntervalMs === 0) return;
671
+ const now = Date.now();
672
+ const last = ctx.getLastDedupeCleanupAt ? ctx.getLastDedupeCleanupAt() : 0;
673
+ if (last && now - last < ctx.dedupe.cleanupIntervalMs) return;
674
+ if (ctx.setLastDedupeCleanupAt) ctx.setLastDedupeCleanupAt(now);
675
+ try {
676
+ await cleanupDedupeStore(ctx.dedupe, now);
677
+ } catch (error) {
678
+ console.error('[rei-standard-amsg-sw] dedupe cleanup failed:', error);
679
+ }
680
+ }
681
+
682
+ async function cleanupDedupeStore(dedupe, now) {
683
+ if (!hasIndexedDB()) {
684
+ const store = memoryDedupeStoreFor(dedupe);
685
+ for (const [key, record] of store.entries()) {
686
+ if (record.expiresAt <= now) store.delete(key);
687
+ }
688
+ return;
689
+ }
690
+
691
+ await withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
692
+ const index = store.index('expiresAt');
693
+ const range = IDBKeyRange.upperBound(now);
694
+ let failed = false;
695
+ const request = index.openCursor(range);
696
+ request.onsuccess = () => {
697
+ if (failed) return;
698
+ const cursor = request.result;
699
+ if (!cursor) {
700
+ resolve(undefined);
701
+ return;
702
+ }
703
+
704
+ const deleteRequest = cursor.delete();
705
+ deleteRequest.onsuccess = () => {
706
+ if (failed) return;
707
+ cursor.continue();
708
+ };
709
+ deleteRequest.onerror = () => {
710
+ if (!failed) {
711
+ failed = true;
712
+ reject(deleteRequest.error || new Error('Failed to delete expired dedupe record'));
713
+ }
714
+ };
715
+ };
716
+ request.onerror = () => reject(request.error || new Error('Failed to scan expired dedupe records'));
717
+ });
718
+ }
719
+
397
720
  function isMultipartPush(payload) {
398
721
  return !!payload &&
399
722
  typeof payload === 'object' &&
@@ -404,14 +727,31 @@ function isMultipartPush(payload) {
404
727
  }
405
728
 
406
729
  async function acceptMultipartChunk(sw, payload, options) {
730
+ const normalized = normalizeMultipartChunk(payload, options);
731
+ if (!normalized) return null;
732
+
733
+ const previous = multipartLocks.get(normalized.id) || Promise.resolve();
734
+ const current = previous
735
+ .catch(() => undefined)
736
+ .then(() => acceptMultipartChunkInternal(sw, normalized, options));
737
+
738
+ multipartLocks.set(normalized.id, current);
739
+ try {
740
+ return await current;
741
+ } finally {
742
+ if (multipartLocks.get(normalized.id) === current) {
743
+ multipartLocks.delete(normalized.id);
744
+ }
745
+ }
746
+ }
747
+
748
+ async function acceptMultipartChunkInternal(sw, normalized, options) {
407
749
  // State machine:
408
750
  // 1. Validate the transport envelope and reject expired chunks before storage.
409
751
  // 2. Drop already-completed multipart ids using the short-lived done marker.
410
752
  // 3. Expire any stale pending record for this id before accepting a new one.
411
753
  // 4. Store only new chunk indexes, track total received bytes, and wait.
412
754
  // 5. Once all indexes are present, restore original JSON and mark done.
413
- const normalized = normalizeMultipartChunk(payload, options);
414
- if (!normalized) return null;
415
755
  if (normalized.expiresAt <= Date.now()) {
416
756
  await dispatchMultipartExpired(sw, {
417
757
  id: normalized.id,
@@ -775,6 +1115,78 @@ function respondToSender(event, message) {
775
1115
  }
776
1116
  }
777
1117
 
1118
+ async function addDedupeRecord(dedupe, record) {
1119
+ if (!hasIndexedDB()) {
1120
+ const store = memoryDedupeStoreFor(dedupe);
1121
+ if (store.has(record.key)) return false;
1122
+ store.set(record.key, cloneRecord(record));
1123
+ return true;
1124
+ }
1125
+
1126
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1127
+ let settled = false;
1128
+ const request = store.add(record);
1129
+ request.onsuccess = () => {
1130
+ settled = true;
1131
+ resolve(true);
1132
+ };
1133
+ request.onerror = (event) => {
1134
+ settled = true;
1135
+ if (request.error && request.error.name === 'ConstraintError') {
1136
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
1137
+ resolve(false);
1138
+ return;
1139
+ }
1140
+ reject(request.error || new Error('Failed to add dedupe record'));
1141
+ };
1142
+ store.transaction.onerror = () => {
1143
+ if (!settled) reject(store.transaction.error || new Error('Dedupe transaction failed'));
1144
+ };
1145
+ });
1146
+ }
1147
+
1148
+ function readDedupeRecord(dedupe, key) {
1149
+ if (!hasIndexedDB()) {
1150
+ return Promise.resolve(cloneRecord(memoryDedupeStoreFor(dedupe).get(key) || null));
1151
+ }
1152
+
1153
+ return withDedupeStore(dedupe, 'readonly', (store, resolve, reject) => {
1154
+ const request = store.get(key);
1155
+ request.onsuccess = () => resolve(request.result || null);
1156
+ request.onerror = () => reject(request.error || new Error('Failed to read dedupe record'));
1157
+ });
1158
+ }
1159
+
1160
+ function putDedupeRecord(dedupe, record) {
1161
+ if (!record || typeof record.key !== 'string' || !record.key) {
1162
+ return Promise.resolve();
1163
+ }
1164
+
1165
+ if (!hasIndexedDB()) {
1166
+ memoryDedupeStoreFor(dedupe).set(record.key, cloneRecord(record));
1167
+ return Promise.resolve();
1168
+ }
1169
+
1170
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1171
+ const request = store.put(record);
1172
+ request.onsuccess = () => resolve(undefined);
1173
+ request.onerror = () => reject(request.error || new Error('Failed to put dedupe record'));
1174
+ });
1175
+ }
1176
+
1177
+ function deleteDedupeRecord(dedupe, key) {
1178
+ if (!hasIndexedDB()) {
1179
+ memoryDedupeStoreFor(dedupe).delete(key);
1180
+ return Promise.resolve();
1181
+ }
1182
+
1183
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1184
+ const request = store.delete(key);
1185
+ request.onsuccess = () => resolve(undefined);
1186
+ request.onerror = () => reject(request.error || new Error('Failed to delete dedupe record'));
1187
+ });
1188
+ }
1189
+
778
1190
  function readMultipartPending(id) {
779
1191
  return readStoreRecord(REI_SW_MULTIPART_STORE, id);
780
1192
  }
@@ -900,12 +1312,27 @@ async function withDatabaseStore(storeName, mode, handler) {
900
1312
  });
901
1313
  }
902
1314
 
1315
+ async function withDedupeStore(dedupe, mode, handler) {
1316
+ const db = await openDedupeDatabase(dedupe);
1317
+ return new Promise((resolve, reject) => {
1318
+ const transaction = db.transaction(dedupe.storeName, mode);
1319
+ const store = transaction.objectStore(dedupe.storeName);
1320
+ transaction.onerror = () => reject(transaction.error || new Error('Dedupe transaction failed'));
1321
+ Promise.resolve(handler(store, resolve, reject)).catch(reject);
1322
+ });
1323
+ }
1324
+
903
1325
  function hasIndexedDB() {
904
1326
  return typeof indexedDB !== 'undefined' &&
905
1327
  indexedDB &&
906
1328
  typeof indexedDB.open === 'function';
907
1329
  }
908
1330
 
1331
+ function memoryDedupeStoreFor(dedupe) {
1332
+ if (!dedupe._memoryStore) dedupe._memoryStore = new Map();
1333
+ return dedupe._memoryStore;
1334
+ }
1335
+
909
1336
  function memoryStoreFor(storeName) {
910
1337
  if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
911
1338
  if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
@@ -918,6 +1345,37 @@ function cloneRecord(record) {
918
1345
  return JSON.parse(JSON.stringify(record));
919
1346
  }
920
1347
 
1348
+ function openDedupeDatabase(dedupe) {
1349
+ const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`;
1350
+ const cached = dedupeDbCache.get(cacheKey);
1351
+ if (cached) return Promise.resolve(cached);
1352
+
1353
+ return new Promise((resolve, reject) => {
1354
+ const request = indexedDB.open(dedupe.dbName, 1);
1355
+
1356
+ request.onupgradeneeded = () => {
1357
+ const db = request.result;
1358
+ const store = db.objectStoreNames.contains(dedupe.storeName)
1359
+ ? request.transaction.objectStore(dedupe.storeName)
1360
+ : db.createObjectStore(dedupe.storeName, { keyPath: 'key' });
1361
+ if (store && !store.indexNames.contains('expiresAt')) {
1362
+ store.createIndex('expiresAt', 'expiresAt', { unique: false });
1363
+ }
1364
+ };
1365
+
1366
+ request.onsuccess = () => {
1367
+ const db = request.result;
1368
+ dedupeDbCache.set(cacheKey, db);
1369
+ db.onversionchange = () => {
1370
+ db.close();
1371
+ dedupeDbCache.delete(cacheKey);
1372
+ };
1373
+ resolve(db);
1374
+ };
1375
+ request.onerror = () => reject(request.error || new Error('Failed to open dedupe database'));
1376
+ });
1377
+ }
1378
+
921
1379
  function openQueueDatabase() {
922
1380
  if (cachedDB) return Promise.resolve(cachedDB);
923
1381
 
@@ -998,4 +1456,4 @@ async function removeQueuedRequest(id) {
998
1456
  });
999
1457
  }
1000
1458
 
1001
- export { REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, installReiSW };
1459
+ export { REI_AMSG_DELIVER_MESSAGE_TYPE, REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, installReiSW };