@rei-standard/amsg-sw 2.0.1 → 2.1.0-next.2
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 +113 -2
- package/dist/index.cjs +394 -11
- package/dist/index.d.cts +573 -23
- package/dist/index.d.ts +573 -23
- package/dist/index.mjs +394 -11
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -3,9 +3,108 @@
|
|
|
3
3
|
`@rei-standard/amsg-sw` 是 ReiStandard 主动消息标准的 Service Worker 插件包,目标是让推送展示和离线重试“开箱即用”。
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## v2.1.0 — 按 kind 分发的客户端事件
|
|
7
|
+
|
|
8
|
+
2.1.0 跟随 `@rei-standard/amsg-shared` 的三轴 push schema:每条 push 现在通过 `payload.messageKind`(`content` / `reasoning` / `tool_request` / `error`)区分内容类型。SW 在收到 push 后会做两件事:
|
|
9
|
+
|
|
10
|
+
1. **永远** 通过 `postMessage` 把 payload 广播给所有受控窗口(包括 `includeUncontrolled: true` 的未受控窗口)。
|
|
11
|
+
2. **仅当** `messageKind === 'content'` 或 payload 没有 `messageKind`(2.0.x 老 payload 的回退路径)时,才调用 `showNotification`。`reasoning` / `tool_request` / `error` 三种 kind 一律不弹通知——业务在 app 内通过 postMessage 通道自行渲染。
|
|
12
|
+
|
|
13
|
+
### 新增导出 `REI_SW_EVENT`
|
|
14
|
+
|
|
15
|
+
事件名由 SW 在每次广播时打在 `e.data.event` 上:
|
|
16
|
+
|
|
17
|
+
| 常量 | 字符串值 | 触发条件 |
|
|
18
|
+
|------|---------|---------|
|
|
19
|
+
| `REI_SW_EVENT.CONTENT_RECEIVED` | `'rei-amsg-content-received'` | `payload.messageKind === 'content'` |
|
|
20
|
+
| `REI_SW_EVENT.REASONING_RECEIVED` | `'rei-amsg-reasoning-received'` | `payload.messageKind === 'reasoning'` |
|
|
21
|
+
| `REI_SW_EVENT.TOOL_REQUEST_RECEIVED` | `'rei-amsg-tool-request-received'` | `payload.messageKind === 'tool_request'` |
|
|
22
|
+
| `REI_SW_EVENT.ERROR_RECEIVED` | `'rei-amsg-error-received'` | `payload.messageKind === 'error'` |
|
|
23
|
+
| `REI_SW_EVENT.MULTIPART_EXPIRED` | `'rei-amsg-multipart-expired'` | `_multipart` 分片 TTL 到期仍未收齐 |
|
|
24
|
+
| `REI_SW_EVENT.UNKNOWN_RECEIVED` | `'rei-amsg-unknown-received'` | 缺 `messageKind`(2.0.x 老 payload / blob envelope) |
|
|
25
|
+
|
|
26
|
+
### 客户端订阅示例
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
navigator.serviceWorker.addEventListener('message', (e) => {
|
|
30
|
+
if (e.data?.type !== 'REI_AMSG_PUSH') return;
|
|
31
|
+
switch (e.data.event) {
|
|
32
|
+
case 'rei-amsg-content-received': /* 渲染 app 内消息 */ break;
|
|
33
|
+
case 'rei-amsg-reasoning-received': /* 渲染思考中 UI */ break;
|
|
34
|
+
case 'rei-amsg-tool-request-received': /* 弹出工具执行确认 */ break;
|
|
35
|
+
case 'rei-amsg-error-received': /* 显示错误 toast */ break;
|
|
36
|
+
case 'rei-amsg-multipart-expired': /* 观测 transport 缺片 */ break;
|
|
37
|
+
case 'rei-amsg-unknown-received': /* 2.0.x 老 payload 的兼容路径 */ break;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Blob envelope
|
|
43
|
+
|
|
44
|
+
当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。
|
|
45
|
+
|
|
46
|
+
### Generic multipart transport(next)
|
|
47
|
+
|
|
48
|
+
next 阶段移除了旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format。现在 `_multipart` 是统一 transport kind,任何原始 payload 都可以被包起来:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"messageKind": "_multipart",
|
|
53
|
+
"multipart": {
|
|
54
|
+
"version": 1,
|
|
55
|
+
"id": "mp_<uuid>",
|
|
56
|
+
"index": 1,
|
|
57
|
+
"total": 4,
|
|
58
|
+
"encoding": "json-utf8-base64url",
|
|
59
|
+
"originalMessageKind": "reasoning",
|
|
60
|
+
"createdAt": 1710000000000,
|
|
61
|
+
"ttlMs": 60000
|
|
62
|
+
},
|
|
63
|
+
"chunk": "base64url..."
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
SW 收到 `_multipart` 后会先写 IndexedDB,支持乱序、重复分片和 SW 重启恢复。未收齐时不 `postMessage`、不 `showNotification`。收齐后按 `index` 拼回原始 JSON payload,删除 pending,写短期 done 标记避免推送服务重投递造成二次业务事件,然后递归走普通 `messageKind` 分发。
|
|
68
|
+
|
|
69
|
+
配置:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
installReiSW(self, {
|
|
73
|
+
defaultIcon: '/icon-192x192.png',
|
|
74
|
+
defaultBadge: '/badge-72x72.png',
|
|
75
|
+
multipart: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
ttlMs: 60_000,
|
|
78
|
+
maxTotalBytes: 256_000,
|
|
79
|
+
maxChunks: 128,
|
|
80
|
+
cleanupIntervalMs: 15 * 60_000
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
TTL 到期仍未收齐时,SW 会清理 pending 并广播:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
{
|
|
89
|
+
type: 'REI_AMSG_PUSH',
|
|
90
|
+
event: 'rei-amsg-multipart-expired',
|
|
91
|
+
payload: { id, received, total, originalMessageKind }
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
业务应用只订阅普通事件即可。`content` multipart 收齐后照常弹通知;`reasoning` / `tool_request` / `error` 仍默认不弹通知。
|
|
96
|
+
|
|
97
|
+
### 升级注意事项
|
|
98
|
+
|
|
99
|
+
- 想给 `reasoning` / `tool_request` / `error` 也弹通知的业务:必须自行在 app 内监听上面的 postMessage 事件、调 `Notification` 或 `registration.showNotification`。SW 默认不再为它们弹通知。
|
|
100
|
+
- 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;next 版本只会把完整还原后的 reasoning payload 发给 client。
|
|
101
|
+
- 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。
|
|
102
|
+
- 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。
|
|
103
|
+
|
|
6
104
|
## 功能概览
|
|
7
105
|
|
|
8
|
-
- 处理 `push`
|
|
106
|
+
- 处理 `push` 事件:按 `messageKind` 三轴 schema 分发到客户端 + 仅 `content` 走 `showNotification`
|
|
107
|
+
- 透明重组 `_multipart` transport:应用层只收到完整原始 payload
|
|
9
108
|
- 处理 `message` 事件:支持离线请求入队与主动冲刷队列
|
|
10
109
|
- 处理 `sync` 事件:在网络恢复后自动重试队列请求
|
|
11
110
|
- 使用 IndexedDB 存储待发送请求,避免页面关闭后丢失
|
|
@@ -25,7 +124,8 @@ import { installReiSW } from '@rei-standard/amsg-sw';
|
|
|
25
124
|
|
|
26
125
|
installReiSW(self, {
|
|
27
126
|
defaultIcon: '/icon-192x192.png',
|
|
28
|
-
defaultBadge: '/badge-72x72.png'
|
|
127
|
+
defaultBadge: '/badge-72x72.png',
|
|
128
|
+
multipart: { enabled: true }
|
|
29
129
|
});
|
|
30
130
|
|
|
31
131
|
// 业务侧自行实现点击跳转
|
|
@@ -97,8 +197,19 @@ export async function enqueueRequestToSW(requestPayload) {
|
|
|
97
197
|
## 导出 API(Exports)
|
|
98
198
|
|
|
99
199
|
- `installReiSW`
|
|
200
|
+
- `REI_SW_EVENT` — 2.1.0 新增,按 kind 分发的客户端事件名
|
|
201
|
+
- `REI_AMSG_POSTMESSAGE_TYPE` — 2.1.0 新增,SW → client 广播信封的 `type` 字段(恒为 `'REI_AMSG_PUSH'`)
|
|
100
202
|
- `REI_SW_MESSAGE_TYPE`
|
|
101
203
|
|
|
204
|
+
`REI_SW_EVENT` 包含(详见上文 v2.1.0 章节):
|
|
205
|
+
|
|
206
|
+
- `CONTENT_RECEIVED`
|
|
207
|
+
- `REASONING_RECEIVED`
|
|
208
|
+
- `TOOL_REQUEST_RECEIVED`
|
|
209
|
+
- `ERROR_RECEIVED`
|
|
210
|
+
- `MULTIPART_EXPIRED`
|
|
211
|
+
- `UNKNOWN_RECEIVED`
|
|
212
|
+
|
|
102
213
|
`REI_SW_MESSAGE_TYPE` 包含:
|
|
103
214
|
|
|
104
215
|
- `ENQUEUE_REQUEST`
|
package/dist/index.cjs
CHANGED
|
@@ -19,14 +19,38 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
+
REI_AMSG_POSTMESSAGE_TYPE: () => REI_AMSG_POSTMESSAGE_TYPE,
|
|
23
|
+
REI_SW_EVENT: () => REI_SW_EVENT,
|
|
22
24
|
REI_SW_MESSAGE_TYPE: () => REI_SW_MESSAGE_TYPE,
|
|
23
25
|
installReiSW: () => installReiSW
|
|
24
26
|
});
|
|
25
27
|
module.exports = __toCommonJS(src_exports);
|
|
26
28
|
var REI_SW_DB_NAME = "rei-sw";
|
|
27
29
|
var REI_SW_DB_STORE = "request-outbox";
|
|
28
|
-
var
|
|
30
|
+
var REI_SW_MULTIPART_STORE = "multipart-pending";
|
|
31
|
+
var REI_SW_MULTIPART_DONE_STORE = "multipart-done";
|
|
32
|
+
var REI_SW_DB_VERSION = 2;
|
|
29
33
|
var REI_SW_SYNC_TAG = "rei-sw-flush-request-outbox";
|
|
34
|
+
var MULTIPART_MESSAGE_KIND = "_multipart";
|
|
35
|
+
var MULTIPART_ENCODING = "json-utf8-base64url";
|
|
36
|
+
var DEFAULT_MULTIPART_OPTIONS = Object.freeze({
|
|
37
|
+
enabled: true,
|
|
38
|
+
ttlMs: 6e4,
|
|
39
|
+
maxTotalBytes: 256e3,
|
|
40
|
+
maxChunks: 128,
|
|
41
|
+
cleanupIntervalMs: 15 * 6e4
|
|
42
|
+
});
|
|
43
|
+
var memoryMultipartPending = /* @__PURE__ */ new Map();
|
|
44
|
+
var memoryMultipartDone = /* @__PURE__ */ new Map();
|
|
45
|
+
var REI_AMSG_POSTMESSAGE_TYPE = "REI_AMSG_PUSH";
|
|
46
|
+
var REI_SW_EVENT = Object.freeze({
|
|
47
|
+
CONTENT_RECEIVED: "rei-amsg-content-received",
|
|
48
|
+
REASONING_RECEIVED: "rei-amsg-reasoning-received",
|
|
49
|
+
TOOL_REQUEST_RECEIVED: "rei-amsg-tool-request-received",
|
|
50
|
+
ERROR_RECEIVED: "rei-amsg-error-received",
|
|
51
|
+
MULTIPART_EXPIRED: "rei-amsg-multipart-expired",
|
|
52
|
+
UNKNOWN_RECEIVED: "rei-amsg-unknown-received"
|
|
53
|
+
});
|
|
30
54
|
var REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
31
55
|
ENQUEUE_REQUEST: "REI_ENQUEUE_REQUEST",
|
|
32
56
|
FLUSH_QUEUE: "REI_FLUSH_QUEUE",
|
|
@@ -35,17 +59,20 @@ var REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
|
35
59
|
function installReiSW(sw, opts = {}) {
|
|
36
60
|
const defaultIcon = opts.defaultIcon || "/icon-192x192.png";
|
|
37
61
|
const defaultBadge = opts.defaultBadge || "/badge-72x72.png";
|
|
62
|
+
const multipart = normalizeMultipartOptions(opts.multipart);
|
|
63
|
+
let lastMultipartCleanupAt = 0;
|
|
38
64
|
sw.addEventListener("push", (event) => {
|
|
39
65
|
const payload = readPushPayload(event);
|
|
40
66
|
if (!payload) return;
|
|
41
|
-
|
|
67
|
+
event.waitUntil(handlePushPayload(sw, payload, {
|
|
68
|
+
defaultBadge,
|
|
42
69
|
defaultIcon,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
70
|
+
multipart,
|
|
71
|
+
getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
|
|
72
|
+
setLastMultipartCleanupAt: (value) => {
|
|
73
|
+
lastMultipartCleanupAt = value;
|
|
74
|
+
}
|
|
75
|
+
}));
|
|
49
76
|
});
|
|
50
77
|
sw.addEventListener("message", (event) => {
|
|
51
78
|
const message = event.data;
|
|
@@ -65,6 +92,75 @@ function installReiSW(sw, opts = {}) {
|
|
|
65
92
|
event.waitUntil(flushQueuedRequests(sw));
|
|
66
93
|
});
|
|
67
94
|
}
|
|
95
|
+
async function handlePushPayload(sw, payload, ctx) {
|
|
96
|
+
await maybeCleanupMultipart(sw, ctx);
|
|
97
|
+
if (isMultipartPush(payload)) {
|
|
98
|
+
if (!ctx.multipart.enabled) return;
|
|
99
|
+
const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
|
|
100
|
+
if (!restoredPayload) return;
|
|
101
|
+
await handlePushPayload(sw, restoredPayload, ctx);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await dispatchBusinessPayload(sw, payload, {
|
|
105
|
+
defaultIcon: ctx.defaultIcon,
|
|
106
|
+
defaultBadge: ctx.defaultBadge
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function dispatchBusinessPayload(sw, payload, defaults) {
|
|
110
|
+
const eventName = resolveEventName(payload);
|
|
111
|
+
const shouldRenderNotification = isNotificationKind(payload);
|
|
112
|
+
const work = [dispatchPushToClients(sw, eventName, payload)];
|
|
113
|
+
if (shouldRenderNotification) {
|
|
114
|
+
const notification = createNotificationFromPayload(payload, defaults);
|
|
115
|
+
if (notification) {
|
|
116
|
+
work.push(
|
|
117
|
+
sw.registration.showNotification(notification.title, notification.options)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await Promise.all(work);
|
|
122
|
+
}
|
|
123
|
+
function resolveEventName(payload) {
|
|
124
|
+
const kind = payload && typeof payload === "object" ? payload.messageKind : void 0;
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case "content":
|
|
127
|
+
return REI_SW_EVENT.CONTENT_RECEIVED;
|
|
128
|
+
case "reasoning":
|
|
129
|
+
return REI_SW_EVENT.REASONING_RECEIVED;
|
|
130
|
+
case "tool_request":
|
|
131
|
+
return REI_SW_EVENT.TOOL_REQUEST_RECEIVED;
|
|
132
|
+
case "error":
|
|
133
|
+
return REI_SW_EVENT.ERROR_RECEIVED;
|
|
134
|
+
default:
|
|
135
|
+
return REI_SW_EVENT.UNKNOWN_RECEIVED;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function isNotificationKind(payload) {
|
|
139
|
+
if (!payload || typeof payload !== "object") return false;
|
|
140
|
+
const kind = payload.messageKind;
|
|
141
|
+
if (kind === void 0 || kind === null) return true;
|
|
142
|
+
return kind === "content";
|
|
143
|
+
}
|
|
144
|
+
async function dispatchPushToClients(sw, eventName, payload) {
|
|
145
|
+
try {
|
|
146
|
+
const clientList = await sw.clients.matchAll({
|
|
147
|
+
type: "window",
|
|
148
|
+
includeUncontrolled: true
|
|
149
|
+
});
|
|
150
|
+
const envelope = {
|
|
151
|
+
type: REI_AMSG_POSTMESSAGE_TYPE,
|
|
152
|
+
event: eventName,
|
|
153
|
+
payload
|
|
154
|
+
};
|
|
155
|
+
for (const client of clientList) {
|
|
156
|
+
try {
|
|
157
|
+
client.postMessage(envelope);
|
|
158
|
+
} catch (_postError) {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (_matchError) {
|
|
162
|
+
}
|
|
163
|
+
}
|
|
68
164
|
function readPushPayload(event) {
|
|
69
165
|
if (!event.data) return null;
|
|
70
166
|
try {
|
|
@@ -89,7 +185,7 @@ function createNotificationFromPayload(payload, defaults) {
|
|
|
89
185
|
};
|
|
90
186
|
}
|
|
91
187
|
const pushNotification = payload.notification && typeof payload.notification === "object" ? payload.notification : {};
|
|
92
|
-
const title = pushNotification.title || payload.title || "New notification";
|
|
188
|
+
const title = pushNotification.title || payload.title || payload.contactName && `\u6765\u81EA ${payload.contactName}` || "New notification";
|
|
93
189
|
const body = pushNotification.body || payload.body || payload.message || "";
|
|
94
190
|
const data = payload.data && typeof payload.data === "object" ? { ...payload.data } : {};
|
|
95
191
|
if (data.payload == null) data.payload = payload;
|
|
@@ -108,6 +204,197 @@ function createNotificationFromPayload(payload, defaults) {
|
|
|
108
204
|
}
|
|
109
205
|
};
|
|
110
206
|
}
|
|
207
|
+
function normalizeMultipartOptions(input) {
|
|
208
|
+
const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
209
|
+
return {
|
|
210
|
+
enabled: source.enabled !== false,
|
|
211
|
+
ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_MULTIPART_OPTIONS.ttlMs),
|
|
212
|
+
maxTotalBytes: positiveIntegerOrDefault(
|
|
213
|
+
source.maxTotalBytes,
|
|
214
|
+
DEFAULT_MULTIPART_OPTIONS.maxTotalBytes
|
|
215
|
+
),
|
|
216
|
+
maxChunks: positiveIntegerOrDefault(source.maxChunks, DEFAULT_MULTIPART_OPTIONS.maxChunks),
|
|
217
|
+
cleanupIntervalMs: source.cleanupIntervalMs === 0 ? 0 : positiveIntegerOrDefault(
|
|
218
|
+
source.cleanupIntervalMs,
|
|
219
|
+
DEFAULT_MULTIPART_OPTIONS.cleanupIntervalMs
|
|
220
|
+
)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function positiveIntegerOrDefault(value, fallback) {
|
|
224
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
225
|
+
}
|
|
226
|
+
function isMultipartPush(payload) {
|
|
227
|
+
return !!payload && typeof payload === "object" && payload.messageKind === MULTIPART_MESSAGE_KIND && payload.multipart && typeof payload.multipart === "object" && typeof payload.chunk === "string";
|
|
228
|
+
}
|
|
229
|
+
async function acceptMultipartChunk(sw, payload, options) {
|
|
230
|
+
const normalized = normalizeMultipartChunk(payload, options);
|
|
231
|
+
if (!normalized) return null;
|
|
232
|
+
if (normalized.expiresAt <= Date.now()) {
|
|
233
|
+
await dispatchMultipartExpired(sw, {
|
|
234
|
+
id: normalized.id,
|
|
235
|
+
chunks: {},
|
|
236
|
+
total: normalized.total,
|
|
237
|
+
originalMessageKind: normalized.originalMessageKind
|
|
238
|
+
});
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const done = await readMultipartDone(normalized.id);
|
|
242
|
+
if (done && done.expiresAt > Date.now()) return null;
|
|
243
|
+
if (done) await deleteMultipartDone(normalized.id);
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const existing = await readMultipartPending(normalized.id);
|
|
246
|
+
if (existing && existing.expiresAt <= now) {
|
|
247
|
+
await deleteMultipartPending(existing.id);
|
|
248
|
+
await dispatchMultipartExpired(sw, existing);
|
|
249
|
+
}
|
|
250
|
+
const base = existing && existing.expiresAt > now ? existing : {
|
|
251
|
+
id: normalized.id,
|
|
252
|
+
createdAt: normalized.createdAt,
|
|
253
|
+
expiresAt: normalized.expiresAt,
|
|
254
|
+
ttlMs: normalized.ttlMs,
|
|
255
|
+
total: normalized.total,
|
|
256
|
+
originalMessageKind: normalized.originalMessageKind,
|
|
257
|
+
encoding: normalized.encoding,
|
|
258
|
+
chunks: {},
|
|
259
|
+
receivedBytes: 0
|
|
260
|
+
};
|
|
261
|
+
if (base.total !== normalized.total || base.encoding !== normalized.encoding) {
|
|
262
|
+
await deleteMultipartPending(normalized.id);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (base.chunks[String(normalized.index)] !== void 0) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
base.chunks[String(normalized.index)] = normalized.chunk;
|
|
269
|
+
base.receivedBytes = positiveIntegerOrDefault(base.receivedBytes, 0) + normalized.chunkBytes.byteLength;
|
|
270
|
+
if (base.receivedBytes > options.maxTotalBytes) {
|
|
271
|
+
await deleteMultipartPending(normalized.id);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const received = Object.keys(base.chunks).length;
|
|
275
|
+
if (received < base.total) {
|
|
276
|
+
await writeMultipartPending(base);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
await deleteMultipartPending(base.id);
|
|
280
|
+
let restored;
|
|
281
|
+
try {
|
|
282
|
+
restored = restoreMultipartPayload(base, options);
|
|
283
|
+
} catch (_error) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const doneTtlMs = Math.max(base.ttlMs * 2, base.ttlMs + 1);
|
|
287
|
+
await writeMultipartDone({
|
|
288
|
+
id: base.id,
|
|
289
|
+
expiresAt: Date.now() + doneTtlMs
|
|
290
|
+
});
|
|
291
|
+
return restored;
|
|
292
|
+
}
|
|
293
|
+
function normalizeMultipartChunk(payload, options) {
|
|
294
|
+
const meta = payload.multipart;
|
|
295
|
+
if (!meta || typeof meta !== "object") return null;
|
|
296
|
+
if (meta.version !== 1 || meta.encoding !== MULTIPART_ENCODING) return null;
|
|
297
|
+
if (typeof meta.id !== "string" || !meta.id) return null;
|
|
298
|
+
if (!Number.isInteger(meta.index) || !Number.isInteger(meta.total)) return null;
|
|
299
|
+
if (meta.total <= 0 || meta.total > options.maxChunks) return null;
|
|
300
|
+
if (meta.index <= 0 || meta.index > meta.total) return null;
|
|
301
|
+
let chunkBytes;
|
|
302
|
+
try {
|
|
303
|
+
chunkBytes = base64UrlToBytes(payload.chunk);
|
|
304
|
+
} catch (_error) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const ttlMs = Math.min(
|
|
309
|
+
positiveIntegerOrDefault(meta.ttlMs, options.ttlMs),
|
|
310
|
+
options.ttlMs
|
|
311
|
+
);
|
|
312
|
+
const createdAt = Number.isFinite(meta.createdAt) ? Number(meta.createdAt) : now;
|
|
313
|
+
const expiresAt = createdAt + ttlMs;
|
|
314
|
+
return {
|
|
315
|
+
id: meta.id,
|
|
316
|
+
createdAt,
|
|
317
|
+
expiresAt,
|
|
318
|
+
ttlMs,
|
|
319
|
+
total: meta.total,
|
|
320
|
+
index: meta.index,
|
|
321
|
+
originalMessageKind: typeof meta.originalMessageKind === "string" ? meta.originalMessageKind : null,
|
|
322
|
+
encoding: meta.encoding,
|
|
323
|
+
chunk: payload.chunk,
|
|
324
|
+
chunkBytes
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function restoreMultipartPayload(record, options) {
|
|
328
|
+
const chunks = [];
|
|
329
|
+
let totalBytes = 0;
|
|
330
|
+
for (let index = 1; index <= record.total; index++) {
|
|
331
|
+
const chunk = record.chunks[String(index)];
|
|
332
|
+
if (typeof chunk !== "string") {
|
|
333
|
+
throw new Error("[rei-standard-amsg-sw] multipart missing chunk");
|
|
334
|
+
}
|
|
335
|
+
const bytes = base64UrlToBytes(chunk);
|
|
336
|
+
totalBytes += bytes.byteLength;
|
|
337
|
+
if (totalBytes > options.maxTotalBytes) {
|
|
338
|
+
throw new Error("[rei-standard-amsg-sw] multipart payload exceeds maxTotalBytes");
|
|
339
|
+
}
|
|
340
|
+
chunks.push(bytes);
|
|
341
|
+
}
|
|
342
|
+
const json = new TextDecoder("utf-8", { fatal: false }).decode(concatBytes(chunks));
|
|
343
|
+
return JSON.parse(json);
|
|
344
|
+
}
|
|
345
|
+
async function maybeCleanupMultipart(sw, ctx) {
|
|
346
|
+
if (!ctx.multipart.enabled) return;
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
const last = ctx.getLastMultipartCleanupAt();
|
|
349
|
+
if (last && now - last < ctx.multipart.cleanupIntervalMs) return;
|
|
350
|
+
ctx.setLastMultipartCleanupAt(now);
|
|
351
|
+
try {
|
|
352
|
+
await cleanupMultipartStores(sw, now);
|
|
353
|
+
} catch (_error) {
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function cleanupMultipartStores(sw, now) {
|
|
357
|
+
const pending = await listMultipartPending();
|
|
358
|
+
for (const record of pending) {
|
|
359
|
+
if (record.expiresAt > now) continue;
|
|
360
|
+
await deleteMultipartPending(record.id);
|
|
361
|
+
await dispatchMultipartExpired(sw, record);
|
|
362
|
+
}
|
|
363
|
+
const done = await listMultipartDone();
|
|
364
|
+
for (const record of done) {
|
|
365
|
+
if (record.expiresAt <= now) {
|
|
366
|
+
await deleteMultipartDone(record.id);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async function dispatchMultipartExpired(sw, record) {
|
|
371
|
+
await dispatchPushToClients(sw, REI_SW_EVENT.MULTIPART_EXPIRED, {
|
|
372
|
+
id: record.id,
|
|
373
|
+
received: record.chunks && typeof record.chunks === "object" ? Object.keys(record.chunks).length : 0,
|
|
374
|
+
total: record.total,
|
|
375
|
+
originalMessageKind: record.originalMessageKind
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function base64UrlToBytes(input) {
|
|
379
|
+
const s = String(input).replace(/-/g, "+").replace(/_/g, "/");
|
|
380
|
+
const pad = (4 - s.length % 4) % 4;
|
|
381
|
+
const padded = s + "=".repeat(pad);
|
|
382
|
+
const bin = typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("binary");
|
|
383
|
+
const out = new Uint8Array(bin.length);
|
|
384
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
function concatBytes(chunks) {
|
|
388
|
+
let total = 0;
|
|
389
|
+
for (const chunk of chunks) total += chunk.byteLength;
|
|
390
|
+
const out = new Uint8Array(total);
|
|
391
|
+
let offset = 0;
|
|
392
|
+
for (const chunk of chunks) {
|
|
393
|
+
out.set(chunk, offset);
|
|
394
|
+
offset += chunk.byteLength;
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
111
398
|
async function enqueueAndFlush(sw, event, requestPayload) {
|
|
112
399
|
try {
|
|
113
400
|
const request = normalizeQueuedRequest(requestPayload);
|
|
@@ -215,18 +502,114 @@ function respondToSender(event, message) {
|
|
|
215
502
|
source.postMessage(message);
|
|
216
503
|
}
|
|
217
504
|
}
|
|
505
|
+
function readMultipartPending(id) {
|
|
506
|
+
return readStoreRecord(REI_SW_MULTIPART_STORE, id);
|
|
507
|
+
}
|
|
508
|
+
function writeMultipartPending(record) {
|
|
509
|
+
return putStoreRecord(REI_SW_MULTIPART_STORE, record);
|
|
510
|
+
}
|
|
511
|
+
function deleteMultipartPending(id) {
|
|
512
|
+
return deleteStoreRecord(REI_SW_MULTIPART_STORE, id);
|
|
513
|
+
}
|
|
514
|
+
function listMultipartPending() {
|
|
515
|
+
return listStoreRecords(REI_SW_MULTIPART_STORE);
|
|
516
|
+
}
|
|
517
|
+
function readMultipartDone(id) {
|
|
518
|
+
return readStoreRecord(REI_SW_MULTIPART_DONE_STORE, id);
|
|
519
|
+
}
|
|
520
|
+
function writeMultipartDone(record) {
|
|
521
|
+
return putStoreRecord(REI_SW_MULTIPART_DONE_STORE, record);
|
|
522
|
+
}
|
|
523
|
+
function deleteMultipartDone(id) {
|
|
524
|
+
return deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id);
|
|
525
|
+
}
|
|
526
|
+
function listMultipartDone() {
|
|
527
|
+
return listStoreRecords(REI_SW_MULTIPART_DONE_STORE);
|
|
528
|
+
}
|
|
529
|
+
async function readStoreRecord(storeName, id) {
|
|
530
|
+
if (!hasIndexedDB()) {
|
|
531
|
+
return cloneRecord(memoryStoreFor(storeName).get(id));
|
|
532
|
+
}
|
|
533
|
+
return withDatabaseStore(storeName, "readonly", (store, resolve, reject) => {
|
|
534
|
+
const request = store.get(id);
|
|
535
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
536
|
+
request.onerror = () => reject(request.error || new Error(`Failed to read ${storeName}`));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
async function putStoreRecord(storeName, record) {
|
|
540
|
+
if (!hasIndexedDB()) {
|
|
541
|
+
memoryStoreFor(storeName).set(record.id, cloneRecord(record));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
return withDatabaseStore(storeName, "readwrite", (store, resolve, reject) => {
|
|
545
|
+
const request = store.put(record);
|
|
546
|
+
request.onsuccess = () => resolve(void 0);
|
|
547
|
+
request.onerror = () => reject(request.error || new Error(`Failed to write ${storeName}`));
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async function deleteStoreRecord(storeName, id) {
|
|
551
|
+
if (!hasIndexedDB()) {
|
|
552
|
+
memoryStoreFor(storeName).delete(id);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
return withDatabaseStore(storeName, "readwrite", (store, resolve, reject) => {
|
|
556
|
+
const request = store.delete(id);
|
|
557
|
+
request.onsuccess = () => resolve(void 0);
|
|
558
|
+
request.onerror = () => reject(request.error || new Error(`Failed to delete ${storeName}`));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
async function listStoreRecords(storeName) {
|
|
562
|
+
if (!hasIndexedDB()) {
|
|
563
|
+
return Array.from(memoryStoreFor(storeName).values()).map(cloneRecord);
|
|
564
|
+
}
|
|
565
|
+
return withDatabaseStore(storeName, "readonly", (store, resolve, reject) => {
|
|
566
|
+
const request = store.getAll();
|
|
567
|
+
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
|
|
568
|
+
request.onerror = () => reject(request.error || new Error(`Failed to list ${storeName}`));
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
async function withDatabaseStore(storeName, mode, handler) {
|
|
572
|
+
const db = await openQueueDatabase();
|
|
573
|
+
try {
|
|
574
|
+
return await new Promise((resolve, reject) => {
|
|
575
|
+
const transaction = db.transaction(storeName, mode);
|
|
576
|
+
const store = transaction.objectStore(storeName);
|
|
577
|
+
transaction.onerror = () => reject(transaction.error || new Error("Database transaction failed"));
|
|
578
|
+
Promise.resolve(handler(store, resolve, reject)).catch(reject);
|
|
579
|
+
});
|
|
580
|
+
} finally {
|
|
581
|
+
db.close();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function hasIndexedDB() {
|
|
585
|
+
return typeof indexedDB !== "undefined" && indexedDB && typeof indexedDB.open === "function";
|
|
586
|
+
}
|
|
587
|
+
function memoryStoreFor(storeName) {
|
|
588
|
+
if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
|
|
589
|
+
if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
|
|
590
|
+
throw new Error(`[rei-standard-amsg-sw] unknown memory store: ${storeName}`);
|
|
591
|
+
}
|
|
592
|
+
function cloneRecord(record) {
|
|
593
|
+
if (record == null) return null;
|
|
594
|
+
return JSON.parse(JSON.stringify(record));
|
|
595
|
+
}
|
|
218
596
|
function openQueueDatabase() {
|
|
219
597
|
return new Promise((resolve, reject) => {
|
|
220
598
|
const request = indexedDB.open(REI_SW_DB_NAME, REI_SW_DB_VERSION);
|
|
221
599
|
request.onupgradeneeded = () => {
|
|
222
600
|
const db = request.result;
|
|
223
|
-
|
|
224
|
-
db
|
|
601
|
+
createObjectStoreIfMissing(db, REI_SW_DB_STORE, { keyPath: "id", autoIncrement: true });
|
|
602
|
+
createObjectStoreIfMissing(db, REI_SW_MULTIPART_STORE, { keyPath: "id" });
|
|
603
|
+
createObjectStoreIfMissing(db, REI_SW_MULTIPART_DONE_STORE, { keyPath: "id" });
|
|
225
604
|
};
|
|
226
605
|
request.onsuccess = () => resolve(request.result);
|
|
227
606
|
request.onerror = () => reject(request.error || new Error("Failed to open queue database"));
|
|
228
607
|
});
|
|
229
608
|
}
|
|
609
|
+
function createObjectStoreIfMissing(db, name, options) {
|
|
610
|
+
if (db.objectStoreNames.contains(name)) return;
|
|
611
|
+
db.createObjectStore(name, options);
|
|
612
|
+
}
|
|
230
613
|
async function withQueueStore(mode, handler) {
|
|
231
614
|
const db = await openQueueDatabase();
|
|
232
615
|
try {
|