@rei-standard/amsg-sw 2.1.1 → 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 +394 -33
- package/dist/index.d.cts +473 -33
- package/dist/index.d.ts +473 -33
- package/dist/index.mjs +394 -33
- package/package.json +3 -3
package/dist/index.d.cts
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';
|
|
@@ -67,6 +71,7 @@ const memoryMultipartPending = new Map();
|
|
|
67
71
|
const memoryMultipartDone = new Map();
|
|
68
72
|
const memoryMultipartChunks = new Map();
|
|
69
73
|
const multipartLocks = new Map();
|
|
74
|
+
const dedupeDbCache = new Map();
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* Wire-level message type for SW → client postMessage envelopes.
|
|
@@ -96,10 +101,13 @@ const REI_SW_EVENT = Object.freeze({
|
|
|
96
101
|
|
|
97
102
|
const REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
98
103
|
ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST',
|
|
104
|
+
DELIVER: 'REI_AMSG_DELIVER',
|
|
99
105
|
FLUSH_QUEUE: 'REI_FLUSH_QUEUE',
|
|
100
106
|
QUEUE_RESULT: 'REI_QUEUE_RESULT'
|
|
101
107
|
});
|
|
102
108
|
|
|
109
|
+
const REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
|
|
110
|
+
|
|
103
111
|
/**
|
|
104
112
|
* @typedef {Object} ReiSWOptions
|
|
105
113
|
* @property {string} [defaultIcon] - Fallback notification icon URL.
|
|
@@ -110,7 +118,14 @@ const REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
|
110
118
|
* @property {number} [multipart.maxTotalBytes=256000]
|
|
111
119
|
* @property {number} [multipart.maxChunks=128]
|
|
112
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 的迁移逻辑。
|
|
113
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]
|
|
114
129
|
*/
|
|
115
130
|
|
|
116
131
|
/**
|
|
@@ -123,20 +138,28 @@ function installReiSW(sw, opts = {}) {
|
|
|
123
138
|
const defaultIcon = opts.defaultIcon || '/icon-192x192.png';
|
|
124
139
|
const defaultBadge = opts.defaultBadge || '/badge-72x72.png';
|
|
125
140
|
const multipart = normalizeMultipartOptions(opts.multipart);
|
|
141
|
+
const dedupe = normalizeDedupeOptions(opts.dedupe);
|
|
126
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
|
+
});
|
|
127
157
|
|
|
128
158
|
sw.addEventListener('push', (event) => {
|
|
129
159
|
const payload = readPushPayload(event);
|
|
130
160
|
if (!payload) return;
|
|
131
161
|
|
|
132
|
-
event.waitUntil(handlePushPayload(sw, payload,
|
|
133
|
-
defaultBadge,
|
|
134
|
-
defaultIcon,
|
|
135
|
-
multipart,
|
|
136
|
-
onBusinessPayload: opts.onBusinessPayload,
|
|
137
|
-
getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
|
|
138
|
-
setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; },
|
|
139
|
-
}));
|
|
162
|
+
event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext('webpush')));
|
|
140
163
|
});
|
|
141
164
|
|
|
142
165
|
sw.addEventListener('message', (event) => {
|
|
@@ -150,6 +173,11 @@ function installReiSW(sw, opts = {}) {
|
|
|
150
173
|
return;
|
|
151
174
|
}
|
|
152
175
|
|
|
176
|
+
if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) {
|
|
177
|
+
event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext()));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
153
181
|
if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) {
|
|
154
182
|
event.waitUntil(flushQueuedRequests(sw));
|
|
155
183
|
}
|
|
@@ -168,18 +196,58 @@ async function handlePushPayload(sw, payload, ctx) {
|
|
|
168
196
|
if (!ctx.multipart.enabled) return;
|
|
169
197
|
const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
|
|
170
198
|
if (!restoredPayload) return;
|
|
171
|
-
|
|
172
|
-
|
|
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 };
|
|
173
208
|
}
|
|
174
209
|
|
|
175
210
|
await dispatchBusinessPayload(sw, payload, {
|
|
176
211
|
defaultIcon: ctx.defaultIcon,
|
|
177
212
|
defaultBadge: ctx.defaultBadge,
|
|
178
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);
|
|
179
220
|
});
|
|
221
|
+
return claim;
|
|
222
|
+
}
|
|
223
|
+
|
|
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
|
+
}
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
async function dispatchBusinessPayload(sw, payload, defaults) {
|
|
250
|
+
async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) {
|
|
183
251
|
const eventName = resolveEventName(payload);
|
|
184
252
|
|
|
185
253
|
let clientList = [];
|
|
@@ -191,47 +259,56 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
|
|
|
191
259
|
} catch (_matchError) {
|
|
192
260
|
// Ignored
|
|
193
261
|
}
|
|
194
|
-
|
|
195
|
-
let shouldRenderNotification = false;
|
|
196
|
-
const showOpt = payload && payload.notification ? payload.notification.show : undefined;
|
|
197
262
|
|
|
198
|
-
|
|
199
|
-
shouldRenderNotification
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
shouldRenderNotification = !hasVisibleClient;
|
|
203
|
-
} else if (showOpt === false) {
|
|
204
|
-
shouldRenderNotification = false;
|
|
205
|
-
} else {
|
|
206
|
-
shouldRenderNotification = isNotificationKind(payload);
|
|
207
|
-
}
|
|
263
|
+
const notificationState = {
|
|
264
|
+
shouldRender: shouldRenderNotification(payload, clientList),
|
|
265
|
+
shown: false,
|
|
266
|
+
};
|
|
208
267
|
|
|
209
268
|
/** @type {Array<Promise<unknown>>} */
|
|
210
|
-
const
|
|
269
|
+
const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
|
|
211
270
|
|
|
212
|
-
if (
|
|
271
|
+
if (notificationState.shouldRender) {
|
|
213
272
|
const notification = createNotificationFromPayload(payload, defaults);
|
|
214
273
|
if (notification) {
|
|
215
|
-
|
|
274
|
+
notificationWork.push(
|
|
216
275
|
sw.registration.showNotification(notification.title, notification.options)
|
|
276
|
+
.then(() => {
|
|
277
|
+
notificationState.shown = true;
|
|
278
|
+
})
|
|
217
279
|
);
|
|
218
280
|
}
|
|
219
281
|
}
|
|
220
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;
|
|
221
291
|
if (typeof defaults.onBusinessPayload === 'function') {
|
|
222
292
|
try {
|
|
223
293
|
const result = defaults.onBusinessPayload(payload);
|
|
224
294
|
if (result && typeof result.then === 'function') {
|
|
225
|
-
|
|
295
|
+
businessWork = Promise.resolve(result).catch(error => {
|
|
226
296
|
console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error);
|
|
227
|
-
})
|
|
297
|
+
});
|
|
228
298
|
}
|
|
229
299
|
} catch (error) {
|
|
230
300
|
console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error);
|
|
231
301
|
}
|
|
232
302
|
}
|
|
233
303
|
|
|
234
|
-
await Promise.all(
|
|
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;
|
|
235
312
|
}
|
|
236
313
|
|
|
237
314
|
/**
|
|
@@ -263,7 +340,6 @@ function resolveEventName(payload) {
|
|
|
263
340
|
* `messageKind: 'content'` renders a notification; everything else
|
|
264
341
|
* (`reasoning`, `tool_request`, `error`) is dispatched silently so
|
|
265
342
|
* apps can render them in-app.
|
|
266
|
-
*
|
|
267
343
|
* Legacy payloads with no `messageKind` field still render a
|
|
268
344
|
* notification — that's the 2.0.x back-compat path.
|
|
269
345
|
*
|
|
@@ -277,6 +353,21 @@ function isNotificationKind(payload) {
|
|
|
277
353
|
return kind === MESSAGE_KIND.CONTENT;
|
|
278
354
|
}
|
|
279
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
|
+
|
|
280
371
|
/**
|
|
281
372
|
* Broadcast a parsed push payload to every controlled client. Failures
|
|
282
373
|
* on individual `postMessage` calls are swallowed — one offline tab
|
|
@@ -367,7 +458,8 @@ function createNotificationFromPayload(payload, defaults) {
|
|
|
367
458
|
renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false),
|
|
368
459
|
requireInteraction: Boolean(
|
|
369
460
|
pushNotification.requireInteraction ?? payload.requireInteraction ?? false
|
|
370
|
-
)
|
|
461
|
+
),
|
|
462
|
+
silent: Boolean(pushNotification.silent ?? payload.silent ?? false)
|
|
371
463
|
}
|
|
372
464
|
};
|
|
373
465
|
}
|
|
@@ -391,10 +483,240 @@ function normalizeMultipartOptions(input) {
|
|
|
391
483
|
};
|
|
392
484
|
}
|
|
393
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
|
+
|
|
394
517
|
function positiveIntegerOrDefault(value, fallback) {
|
|
395
518
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
396
519
|
}
|
|
397
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
|
+
|
|
398
720
|
function isMultipartPush(payload) {
|
|
399
721
|
return !!payload &&
|
|
400
722
|
typeof payload === 'object' &&
|
|
@@ -793,6 +1115,78 @@ function respondToSender(event, message) {
|
|
|
793
1115
|
}
|
|
794
1116
|
}
|
|
795
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
|
+
|
|
796
1190
|
function readMultipartPending(id) {
|
|
797
1191
|
return readStoreRecord(REI_SW_MULTIPART_STORE, id);
|
|
798
1192
|
}
|
|
@@ -918,12 +1312,27 @@ async function withDatabaseStore(storeName, mode, handler) {
|
|
|
918
1312
|
});
|
|
919
1313
|
}
|
|
920
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
|
+
|
|
921
1325
|
function hasIndexedDB() {
|
|
922
1326
|
return typeof indexedDB !== 'undefined' &&
|
|
923
1327
|
indexedDB &&
|
|
924
1328
|
typeof indexedDB.open === 'function';
|
|
925
1329
|
}
|
|
926
1330
|
|
|
1331
|
+
function memoryDedupeStoreFor(dedupe) {
|
|
1332
|
+
if (!dedupe._memoryStore) dedupe._memoryStore = new Map();
|
|
1333
|
+
return dedupe._memoryStore;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
927
1336
|
function memoryStoreFor(storeName) {
|
|
928
1337
|
if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
|
|
929
1338
|
if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
|
|
@@ -936,6 +1345,37 @@ function cloneRecord(record) {
|
|
|
936
1345
|
return JSON.parse(JSON.stringify(record));
|
|
937
1346
|
}
|
|
938
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
|
+
|
|
939
1379
|
function openQueueDatabase() {
|
|
940
1380
|
if (cachedDB) return Promise.resolve(cachedDB);
|
|
941
1381
|
|
|
@@ -1016,4 +1456,4 @@ async function removeQueuedRequest(id) {
|
|
|
1016
1456
|
});
|
|
1017
1457
|
}
|
|
1018
1458
|
|
|
1019
|
-
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 };
|