@rei-standard/amsg-sw 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/dist/index.cjs +409 -35
- package/dist/index.d.cts +495 -37
- package/dist/index.d.ts +495 -37
- package/dist/index.mjs +409 -35
- package/package.json +3 -3
package/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';
|
|
@@ -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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
shouldRenderNotification
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
269
|
+
const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
|
|
210
270
|
|
|
211
|
-
if (
|
|
271
|
+
if (notificationState.shouldRender) {
|
|
212
272
|
const notification = createNotificationFromPayload(payload, defaults);
|
|
213
273
|
if (notification) {
|
|
214
|
-
|
|
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
|
|
224
|
-
|
|
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(
|
|
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 };
|