@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.mjs CHANGED
@@ -7,6 +7,10 @@ var REI_SW_MULTIPART_DONE_STORE = "multipart-done";
7
7
  var REI_SW_MULTIPART_CHUNK_STORE = "multipart-chunk";
8
8
  var REI_SW_DB_VERSION = 3;
9
9
  var cachedDB = null;
10
+ var REI_AMSG_DEDUPE_DB_NAME = "rei_amsg_sw_dedupe_v1";
11
+ var REI_AMSG_DEDUPE_STORE = "delivery-dedupe";
12
+ var DEFAULT_DEDUPE_TTL_MS = 10 * 6e4;
13
+ var DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS = 6e4;
10
14
  var REI_SW_SYNC_TAG = "rei-sw-flush-request-outbox";
11
15
  var MULTIPART_MESSAGE_KIND = "_multipart";
12
16
  var MULTIPART_ENCODING = "json-utf8-base64url";
@@ -20,6 +24,8 @@ var DEFAULT_MULTIPART_OPTIONS = Object.freeze({
20
24
  var memoryMultipartPending = /* @__PURE__ */ new Map();
21
25
  var memoryMultipartDone = /* @__PURE__ */ new Map();
22
26
  var memoryMultipartChunks = /* @__PURE__ */ new Map();
27
+ var multipartLocks = /* @__PURE__ */ new Map();
28
+ var dedupeDbCache = /* @__PURE__ */ new Map();
23
29
  var REI_AMSG_POSTMESSAGE_TYPE = "REI_AMSG_PUSH";
24
30
  var REI_SW_EVENT = Object.freeze({
25
31
  CONTENT_RECEIVED: "rei-amsg-content-received",
@@ -31,27 +37,39 @@ var REI_SW_EVENT = Object.freeze({
31
37
  });
32
38
  var REI_SW_MESSAGE_TYPE = Object.freeze({
33
39
  ENQUEUE_REQUEST: "REI_ENQUEUE_REQUEST",
40
+ DELIVER: "REI_AMSG_DELIVER",
34
41
  FLUSH_QUEUE: "REI_FLUSH_QUEUE",
35
42
  QUEUE_RESULT: "REI_QUEUE_RESULT"
36
43
  });
44
+ var REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
37
45
  function installReiSW(sw, opts = {}) {
38
46
  const defaultIcon = opts.defaultIcon || "/icon-192x192.png";
39
47
  const defaultBadge = opts.defaultBadge || "/badge-72x72.png";
40
48
  const multipart = normalizeMultipartOptions(opts.multipart);
49
+ const dedupe = normalizeDedupeOptions(opts.dedupe);
41
50
  let lastMultipartCleanupAt = 0;
51
+ let lastDedupeCleanupAt = 0;
52
+ const makeDeliveryContext = (source) => ({
53
+ defaultBadge,
54
+ defaultIcon,
55
+ dedupe,
56
+ multipart,
57
+ onDuplicate: opts.onDuplicate,
58
+ onBusinessPayload: opts.onBusinessPayload,
59
+ source,
60
+ getLastDedupeCleanupAt: () => lastDedupeCleanupAt,
61
+ setLastDedupeCleanupAt: (value) => {
62
+ lastDedupeCleanupAt = value;
63
+ },
64
+ getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
65
+ setLastMultipartCleanupAt: (value) => {
66
+ lastMultipartCleanupAt = value;
67
+ }
68
+ });
42
69
  sw.addEventListener("push", (event) => {
43
70
  const payload = readPushPayload(event);
44
71
  if (!payload) return;
45
- event.waitUntil(handlePushPayload(sw, payload, {
46
- defaultBadge,
47
- defaultIcon,
48
- multipart,
49
- onBusinessPayload: opts.onBusinessPayload,
50
- getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
51
- setLastMultipartCleanupAt: (value) => {
52
- lastMultipartCleanupAt = value;
53
- }
54
- }));
72
+ event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext("webpush")));
55
73
  });
56
74
  sw.addEventListener("message", (event) => {
57
75
  const message = event.data;
@@ -62,6 +80,10 @@ function installReiSW(sw, opts = {}) {
62
80
  );
63
81
  return;
64
82
  }
83
+ if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) {
84
+ event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext()));
85
+ return;
86
+ }
65
87
  if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) {
66
88
  event.waitUntil(flushQueuedRequests(sw));
67
89
  }
@@ -77,16 +99,48 @@ async function handlePushPayload(sw, payload, ctx) {
77
99
  if (!ctx.multipart.enabled) return;
78
100
  const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
79
101
  if (!restoredPayload) return;
80
- await handlePushPayload(sw, restoredPayload, ctx);
81
- return;
102
+ return handlePushPayload(sw, restoredPayload, ctx);
103
+ }
104
+ const claim = await claimDedupe(payload, ctx);
105
+ if (claim.duplicate) {
106
+ const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx);
107
+ claim.duplicateNotification = duplicateNotification;
108
+ await notifyDuplicate(payload, claim, ctx);
109
+ return { ...claim, duplicateNotification };
82
110
  }
83
111
  await dispatchBusinessPayload(sw, payload, {
84
112
  defaultIcon: ctx.defaultIcon,
85
113
  defaultBadge: ctx.defaultBadge,
86
114
  onBusinessPayload: ctx.onBusinessPayload
115
+ }, async (intermediateResult) => {
116
+ await updateDedupeNotificationState(claim, ctx, intermediateResult);
87
117
  });
118
+ return claim;
119
+ }
120
+ async function handleDeliverMessage(sw, event, message, ctx) {
121
+ let result = {};
122
+ try {
123
+ if (!Object.prototype.hasOwnProperty.call(message, "payload")) {
124
+ throw new Error("[rei-standard-amsg-sw] REI_AMSG_DELIVER requires payload");
125
+ }
126
+ const source = typeof message.source === "string" && message.source ? message.source : "message";
127
+ result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {};
128
+ respondToSender(event, {
129
+ ok: true,
130
+ duplicate: Boolean(result.duplicate),
131
+ key: result.key,
132
+ requestId: message.requestId
133
+ });
134
+ } catch (error) {
135
+ respondToSender(event, {
136
+ ok: false,
137
+ error: error instanceof Error ? error.message : "Failed to deliver payload",
138
+ key: result && result.key,
139
+ requestId: message.requestId
140
+ });
141
+ }
88
142
  }
89
- async function dispatchBusinessPayload(sw, payload, defaults) {
143
+ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) {
90
144
  const eventName = resolveEventName(payload);
91
145
  let clientList = [];
92
146
  try {
@@ -96,40 +150,41 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
96
150
  });
97
151
  } catch (_matchError) {
98
152
  }
99
- let shouldRenderNotification = false;
100
- const showOpt = payload && payload.notification ? payload.notification.show : void 0;
101
- if (showOpt === "always") {
102
- shouldRenderNotification = true;
103
- } else if (showOpt === "when-hidden") {
104
- const hasVisibleClient = clientList.some((client) => client.visibilityState === "visible");
105
- shouldRenderNotification = !hasVisibleClient;
106
- } else if (showOpt === false) {
107
- shouldRenderNotification = false;
108
- } else {
109
- shouldRenderNotification = isNotificationKind(payload);
110
- }
111
- const work = [dispatchPushToClients(sw, eventName, payload, clientList)];
112
- if (shouldRenderNotification) {
153
+ const notificationState = {
154
+ shouldRender: shouldRenderNotification(payload, clientList),
155
+ shown: false
156
+ };
157
+ const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
158
+ if (notificationState.shouldRender) {
113
159
  const notification = createNotificationFromPayload(payload, defaults);
114
160
  if (notification) {
115
- work.push(
116
- sw.registration.showNotification(notification.title, notification.options)
161
+ notificationWork.push(
162
+ sw.registration.showNotification(notification.title, notification.options).then(() => {
163
+ notificationState.shown = true;
164
+ })
117
165
  );
118
166
  }
119
167
  }
168
+ let businessWork = null;
120
169
  if (typeof defaults.onBusinessPayload === "function") {
121
170
  try {
122
171
  const result = defaults.onBusinessPayload(payload);
123
- if (result instanceof Promise) {
124
- work.push(result.catch((error) => {
172
+ if (result && typeof result.then === "function") {
173
+ businessWork = Promise.resolve(result).catch((error) => {
125
174
  console.error("[rei-standard-amsg-sw] onBusinessPayload promise rejected:", error);
126
- }));
175
+ });
127
176
  }
128
177
  } catch (error) {
129
178
  console.error("[rei-standard-amsg-sw] onBusinessPayload error:", error);
130
179
  }
131
180
  }
132
- await Promise.all(work);
181
+ await Promise.all(notificationWork);
182
+ const settledResult = { eventName, notification: notificationState };
183
+ if (typeof onNotificationSettled === "function") {
184
+ await onNotificationSettled(settledResult);
185
+ }
186
+ if (businessWork) await businessWork;
187
+ return settledResult;
133
188
  }
134
189
  function resolveEventName(payload) {
135
190
  const kind = payload && typeof payload === "object" ? payload.messageKind : void 0;
@@ -152,6 +207,19 @@ function isNotificationKind(payload) {
152
207
  if (kind === void 0 || kind === null) return true;
153
208
  return kind === MESSAGE_KIND.CONTENT;
154
209
  }
210
+ function shouldRenderNotification(payload, clientList) {
211
+ const showOpt = payload && payload.notification ? payload.notification.show : void 0;
212
+ if (showOpt === "always") {
213
+ return true;
214
+ }
215
+ if (showOpt === "when-hidden") {
216
+ return !clientList.some((client) => client.visibilityState === "visible");
217
+ }
218
+ if (showOpt === false) {
219
+ return false;
220
+ }
221
+ return isNotificationKind(payload);
222
+ }
155
223
  async function dispatchPushToClients(sw, eventName, payload, preFetchedClientList = null) {
156
224
  try {
157
225
  const clientList = preFetchedClientList || await sw.clients.matchAll({
@@ -196,7 +264,7 @@ function createNotificationFromPayload(payload, defaults) {
196
264
  };
197
265
  }
198
266
  const pushNotification = payload.notification && typeof payload.notification === "object" ? payload.notification : {};
199
- const title = pushNotification.title || payload.title || payload.contactName || "New notification";
267
+ const title = pushNotification.title || payload.title || payload.contactName && `\u6765\u81EA ${payload.contactName}` || "New notification";
200
268
  const body = pushNotification.body || payload.body || payload.message || "";
201
269
  const data = pushNotification.data && typeof pushNotification.data === "object" ? { ...pushNotification.data } : payload.data && typeof payload.data === "object" ? { ...payload.data } : {};
202
270
  if (data.payload == null) data.payload = payload;
@@ -211,7 +279,8 @@ function createNotificationFromPayload(payload, defaults) {
211
279
  renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false),
212
280
  requireInteraction: Boolean(
213
281
  pushNotification.requireInteraction ?? payload.requireInteraction ?? false
214
- )
282
+ ),
283
+ silent: Boolean(pushNotification.silent ?? payload.silent ?? false)
215
284
  }
216
285
  };
217
286
  }
@@ -231,15 +300,218 @@ function normalizeMultipartOptions(input) {
231
300
  )
232
301
  };
233
302
  }
303
+ function normalizeDedupeOptions(input) {
304
+ const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
305
+ if (Object.prototype.hasOwnProperty.call(source, "storeName")) {
306
+ throw new Error(
307
+ "[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"
308
+ );
309
+ }
310
+ return {
311
+ enabled: source.enabled !== false,
312
+ ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_DEDUPE_TTL_MS),
313
+ cleanupIntervalMs: source.cleanupIntervalMs === 0 ? 0 : positiveIntegerOrDefault(
314
+ source.cleanupIntervalMs,
315
+ DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS
316
+ ),
317
+ key: typeof source.key === "function" ? source.key : null,
318
+ dbName: typeof source.dbName === "string" && source.dbName.trim() ? source.dbName.trim() : REI_AMSG_DEDUPE_DB_NAME,
319
+ storeName: REI_AMSG_DEDUPE_STORE,
320
+ _memoryStore: /* @__PURE__ */ new Map()
321
+ };
322
+ }
234
323
  function positiveIntegerOrDefault(value, fallback) {
235
324
  return Number.isInteger(value) && value > 0 ? value : fallback;
236
325
  }
326
+ async function claimDedupe(payload, ctx) {
327
+ if (!ctx.dedupe || ctx.dedupe.enabled === false) {
328
+ return { duplicate: false, key: void 0 };
329
+ }
330
+ const key = resolveDedupeKey(payload, ctx.dedupe);
331
+ if (!key) return { duplicate: false, key: void 0 };
332
+ await maybeCleanupDedupe(ctx);
333
+ const now = Date.now();
334
+ const record = {
335
+ key,
336
+ firstSeenAt: now,
337
+ expiresAt: now + ctx.dedupe.ttlMs,
338
+ source: ctx.source || "unknown",
339
+ messageKind: getPayloadMessageKind(payload),
340
+ notificationShown: false,
341
+ notificationStatePending: true
342
+ };
343
+ if (await addDedupeRecord(ctx.dedupe, record)) {
344
+ return { duplicate: false, key, record };
345
+ }
346
+ const existing = await readDedupeRecord(ctx.dedupe, key);
347
+ if (existing && existing.expiresAt <= now) {
348
+ await deleteDedupeRecord(ctx.dedupe, key);
349
+ if (await addDedupeRecord(ctx.dedupe, record)) {
350
+ return { duplicate: false, key, record };
351
+ }
352
+ }
353
+ return {
354
+ duplicate: true,
355
+ key,
356
+ record,
357
+ existing: existing || null
358
+ };
359
+ }
360
+ async function updateDedupeNotificationState(claim, ctx, dispatchResult) {
361
+ if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return;
362
+ if (!dispatchResult || !dispatchResult.notification) return;
363
+ const notification = dispatchResult.notification;
364
+ const next = {
365
+ ...claim.record,
366
+ notificationShown: notification.shown === true,
367
+ notificationStatePending: false
368
+ };
369
+ try {
370
+ await putDedupeRecord(ctx.dedupe, next);
371
+ claim.record = next;
372
+ } catch (error) {
373
+ console.error("[rei-standard-amsg-sw] dedupe notification state update failed:", error);
374
+ }
375
+ }
376
+ async function maybeShowDuplicateNotification(sw, payload, claim, ctx) {
377
+ const existing = claim && claim.existing ? claim.existing : null;
378
+ if (!existing || existing.notificationShown === true) {
379
+ return { shown: false, reason: existing ? "already-shown" : "no-existing-record" };
380
+ }
381
+ if (existing.notificationStatePending === true) {
382
+ return { shown: false, reason: "first-delivery-pending" };
383
+ }
384
+ let clientList = [];
385
+ try {
386
+ clientList = await sw.clients.matchAll({
387
+ type: "window",
388
+ includeUncontrolled: true
389
+ });
390
+ } catch (_matchError) {
391
+ }
392
+ if (!shouldRenderNotification(payload, clientList)) {
393
+ return { shown: false, reason: "policy-suppressed" };
394
+ }
395
+ const notification = createNotificationFromPayload(payload, {
396
+ defaultIcon: ctx.defaultIcon,
397
+ defaultBadge: ctx.defaultBadge
398
+ });
399
+ if (!notification) {
400
+ return { shown: false, reason: "no-notification" };
401
+ }
402
+ await sw.registration.showNotification(notification.title, notification.options);
403
+ const next = {
404
+ ...existing,
405
+ notificationShown: true,
406
+ notificationStatePending: false
407
+ };
408
+ await putDedupeRecord(ctx.dedupe, next);
409
+ return { shown: true, reason: "shown-from-duplicate" };
410
+ }
411
+ function resolveDedupeKey(payload, dedupe) {
412
+ if (typeof dedupe.key === "function") {
413
+ try {
414
+ const custom = dedupe.key(payload);
415
+ return typeof custom === "string" && custom.trim() ? custom.trim() : void 0;
416
+ } catch (error) {
417
+ console.error("[rei-standard-amsg-sw] dedupe.key error:", error);
418
+ return void 0;
419
+ }
420
+ }
421
+ if (!payload || typeof payload !== "object") return void 0;
422
+ for (const field of ["messageId", "id", "dedupeKey"]) {
423
+ const value = payload[field];
424
+ if (typeof value === "string" && value.trim()) return value.trim();
425
+ }
426
+ return void 0;
427
+ }
428
+ function getPayloadMessageKind(payload) {
429
+ return payload && typeof payload === "object" && typeof payload.messageKind === "string" ? payload.messageKind : void 0;
430
+ }
431
+ async function notifyDuplicate(payload, claim, ctx) {
432
+ if (typeof ctx.onDuplicate !== "function") return;
433
+ const existing = claim.existing || {};
434
+ const info = {
435
+ key: claim.key,
436
+ source: ctx.source || "unknown",
437
+ messageKind: getPayloadMessageKind(payload),
438
+ firstSeenAt: existing.firstSeenAt,
439
+ existingSource: existing.source,
440
+ existingMessageKind: existing.messageKind,
441
+ existingNotificationShown: existing.notificationShown === true,
442
+ duplicateNotificationShown: claim.duplicateNotification && claim.duplicateNotification.shown === true
443
+ };
444
+ try {
445
+ await ctx.onDuplicate(info);
446
+ } catch (error) {
447
+ console.error("[rei-standard-amsg-sw] onDuplicate error:", error);
448
+ }
449
+ }
450
+ async function maybeCleanupDedupe(ctx) {
451
+ if (!ctx.dedupe || ctx.dedupe.enabled === false || ctx.dedupe.cleanupIntervalMs === 0) return;
452
+ const now = Date.now();
453
+ const last = ctx.getLastDedupeCleanupAt ? ctx.getLastDedupeCleanupAt() : 0;
454
+ if (last && now - last < ctx.dedupe.cleanupIntervalMs) return;
455
+ if (ctx.setLastDedupeCleanupAt) ctx.setLastDedupeCleanupAt(now);
456
+ try {
457
+ await cleanupDedupeStore(ctx.dedupe, now);
458
+ } catch (error) {
459
+ console.error("[rei-standard-amsg-sw] dedupe cleanup failed:", error);
460
+ }
461
+ }
462
+ async function cleanupDedupeStore(dedupe, now) {
463
+ if (!hasIndexedDB()) {
464
+ const store = memoryDedupeStoreFor(dedupe);
465
+ for (const [key, record] of store.entries()) {
466
+ if (record.expiresAt <= now) store.delete(key);
467
+ }
468
+ return;
469
+ }
470
+ await withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
471
+ const index = store.index("expiresAt");
472
+ const range = IDBKeyRange.upperBound(now);
473
+ let failed = false;
474
+ const request = index.openCursor(range);
475
+ request.onsuccess = () => {
476
+ if (failed) return;
477
+ const cursor = request.result;
478
+ if (!cursor) {
479
+ resolve(void 0);
480
+ return;
481
+ }
482
+ const deleteRequest = cursor.delete();
483
+ deleteRequest.onsuccess = () => {
484
+ if (failed) return;
485
+ cursor.continue();
486
+ };
487
+ deleteRequest.onerror = () => {
488
+ if (!failed) {
489
+ failed = true;
490
+ reject(deleteRequest.error || new Error("Failed to delete expired dedupe record"));
491
+ }
492
+ };
493
+ };
494
+ request.onerror = () => reject(request.error || new Error("Failed to scan expired dedupe records"));
495
+ });
496
+ }
237
497
  function isMultipartPush(payload) {
238
498
  return !!payload && typeof payload === "object" && payload.messageKind === MULTIPART_MESSAGE_KIND && payload.multipart && typeof payload.multipart === "object" && typeof payload.chunk === "string";
239
499
  }
240
500
  async function acceptMultipartChunk(sw, payload, options) {
241
501
  const normalized = normalizeMultipartChunk(payload, options);
242
502
  if (!normalized) return null;
503
+ const previous = multipartLocks.get(normalized.id) || Promise.resolve();
504
+ const current = previous.catch(() => void 0).then(() => acceptMultipartChunkInternal(sw, normalized, options));
505
+ multipartLocks.set(normalized.id, current);
506
+ try {
507
+ return await current;
508
+ } finally {
509
+ if (multipartLocks.get(normalized.id) === current) {
510
+ multipartLocks.delete(normalized.id);
511
+ }
512
+ }
513
+ }
514
+ async function acceptMultipartChunkInternal(sw, normalized, options) {
243
515
  if (normalized.expiresAt <= Date.now()) {
244
516
  await dispatchMultipartExpired(sw, {
245
517
  id: normalized.id,
@@ -538,6 +810,69 @@ function respondToSender(event, message) {
538
810
  source.postMessage(message);
539
811
  }
540
812
  }
813
+ async function addDedupeRecord(dedupe, record) {
814
+ if (!hasIndexedDB()) {
815
+ const store = memoryDedupeStoreFor(dedupe);
816
+ if (store.has(record.key)) return false;
817
+ store.set(record.key, cloneRecord(record));
818
+ return true;
819
+ }
820
+ return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
821
+ let settled = false;
822
+ const request = store.add(record);
823
+ request.onsuccess = () => {
824
+ settled = true;
825
+ resolve(true);
826
+ };
827
+ request.onerror = (event) => {
828
+ settled = true;
829
+ if (request.error && request.error.name === "ConstraintError") {
830
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
831
+ resolve(false);
832
+ return;
833
+ }
834
+ reject(request.error || new Error("Failed to add dedupe record"));
835
+ };
836
+ store.transaction.onerror = () => {
837
+ if (!settled) reject(store.transaction.error || new Error("Dedupe transaction failed"));
838
+ };
839
+ });
840
+ }
841
+ function readDedupeRecord(dedupe, key) {
842
+ if (!hasIndexedDB()) {
843
+ return Promise.resolve(cloneRecord(memoryDedupeStoreFor(dedupe).get(key) || null));
844
+ }
845
+ return withDedupeStore(dedupe, "readonly", (store, resolve, reject) => {
846
+ const request = store.get(key);
847
+ request.onsuccess = () => resolve(request.result || null);
848
+ request.onerror = () => reject(request.error || new Error("Failed to read dedupe record"));
849
+ });
850
+ }
851
+ function putDedupeRecord(dedupe, record) {
852
+ if (!record || typeof record.key !== "string" || !record.key) {
853
+ return Promise.resolve();
854
+ }
855
+ if (!hasIndexedDB()) {
856
+ memoryDedupeStoreFor(dedupe).set(record.key, cloneRecord(record));
857
+ return Promise.resolve();
858
+ }
859
+ return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
860
+ const request = store.put(record);
861
+ request.onsuccess = () => resolve(void 0);
862
+ request.onerror = () => reject(request.error || new Error("Failed to put dedupe record"));
863
+ });
864
+ }
865
+ function deleteDedupeRecord(dedupe, key) {
866
+ if (!hasIndexedDB()) {
867
+ memoryDedupeStoreFor(dedupe).delete(key);
868
+ return Promise.resolve();
869
+ }
870
+ return withDedupeStore(dedupe, "readwrite", (store, resolve, reject) => {
871
+ const request = store.delete(key);
872
+ request.onsuccess = () => resolve(void 0);
873
+ request.onerror = () => reject(request.error || new Error("Failed to delete dedupe record"));
874
+ });
875
+ }
541
876
  function readMultipartPending(id) {
542
877
  return readStoreRecord(REI_SW_MULTIPART_STORE, id);
543
878
  }
@@ -646,9 +981,22 @@ async function withDatabaseStore(storeName, mode, handler) {
646
981
  Promise.resolve(handler(store, resolve, reject)).catch(reject);
647
982
  });
648
983
  }
984
+ async function withDedupeStore(dedupe, mode, handler) {
985
+ const db = await openDedupeDatabase(dedupe);
986
+ return new Promise((resolve, reject) => {
987
+ const transaction = db.transaction(dedupe.storeName, mode);
988
+ const store = transaction.objectStore(dedupe.storeName);
989
+ transaction.onerror = () => reject(transaction.error || new Error("Dedupe transaction failed"));
990
+ Promise.resolve(handler(store, resolve, reject)).catch(reject);
991
+ });
992
+ }
649
993
  function hasIndexedDB() {
650
994
  return typeof indexedDB !== "undefined" && indexedDB && typeof indexedDB.open === "function";
651
995
  }
996
+ function memoryDedupeStoreFor(dedupe) {
997
+ if (!dedupe._memoryStore) dedupe._memoryStore = /* @__PURE__ */ new Map();
998
+ return dedupe._memoryStore;
999
+ }
652
1000
  function memoryStoreFor(storeName) {
653
1001
  if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
654
1002
  if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
@@ -659,6 +1007,31 @@ function cloneRecord(record) {
659
1007
  if (record == null) return null;
660
1008
  return JSON.parse(JSON.stringify(record));
661
1009
  }
1010
+ function openDedupeDatabase(dedupe) {
1011
+ const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`;
1012
+ const cached = dedupeDbCache.get(cacheKey);
1013
+ if (cached) return Promise.resolve(cached);
1014
+ return new Promise((resolve, reject) => {
1015
+ const request = indexedDB.open(dedupe.dbName, 1);
1016
+ request.onupgradeneeded = () => {
1017
+ const db = request.result;
1018
+ const store = db.objectStoreNames.contains(dedupe.storeName) ? request.transaction.objectStore(dedupe.storeName) : db.createObjectStore(dedupe.storeName, { keyPath: "key" });
1019
+ if (store && !store.indexNames.contains("expiresAt")) {
1020
+ store.createIndex("expiresAt", "expiresAt", { unique: false });
1021
+ }
1022
+ };
1023
+ request.onsuccess = () => {
1024
+ const db = request.result;
1025
+ dedupeDbCache.set(cacheKey, db);
1026
+ db.onversionchange = () => {
1027
+ db.close();
1028
+ dedupeDbCache.delete(cacheKey);
1029
+ };
1030
+ resolve(db);
1031
+ };
1032
+ request.onerror = () => reject(request.error || new Error("Failed to open dedupe database"));
1033
+ });
1034
+ }
662
1035
  function openQueueDatabase() {
663
1036
  if (cachedDB) return Promise.resolve(cachedDB);
664
1037
  return new Promise((resolve, reject) => {
@@ -728,6 +1101,7 @@ async function removeQueuedRequest(id) {
728
1101
  });
729
1102
  }
730
1103
  export {
1104
+ REI_AMSG_DELIVER_MESSAGE_TYPE,
731
1105
  REI_AMSG_POSTMESSAGE_TYPE,
732
1106
  REI_SW_EVENT,
733
1107
  REI_SW_MESSAGE_TYPE,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-sw",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/Tosd0/ReiStandard",
7
+ "url": "git+https://github.com/Tosd0/ReiStandard.git",
8
8
  "directory": "packages/rei-standard-amsg/sw"
9
9
  },
10
10
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "dependencies": {
36
- "@rei-standard/amsg-shared": "0.1.0"
36
+ "@rei-standard/amsg-shared": "0.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",