@rei-standard/amsg-client 2.2.1 → 2.3.0-next.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 +91 -0
- package/dist/index.cjs +98 -5
- package/dist/index.d.cts +116 -4
- package/dist/index.d.ts +116 -4
- package/dist/index.mjs +110 -5
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。
|
|
4
4
|
|
|
5
|
+
## v2.3.0 — Shared push types
|
|
6
|
+
|
|
7
|
+
The client now re-exports `@rei-standard/amsg-shared` 的类型、运行时常量(`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`)、推送 builder(`buildContentPush` 等)和类型守卫(`isContentPush` 等)。调用方可以直接 `import { MessageKind, buildContentPush, isContentPush } from '@rei-standard/amsg-client'`,无需单独再装一个 `@rei-standard/amsg-shared` 依赖。client 本身在运行时不消费这些导出 —— 它们是给同时调 `ReiClient` 又在 Service Worker / 客户端处理推送的 app 用的便利出口。
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// app.js — 用 ReiClient 发即时消息
|
|
11
|
+
import { ReiClient } from '@rei-standard/amsg-client';
|
|
12
|
+
|
|
13
|
+
const client = new ReiClient({
|
|
14
|
+
baseUrl: 'https://instant.example.com',
|
|
15
|
+
instantEncryption: false,
|
|
16
|
+
});
|
|
17
|
+
await client.sendInstant({
|
|
18
|
+
contactName: 'Rei',
|
|
19
|
+
completePrompt: '你是 Rei,用一句话提醒用户带伞',
|
|
20
|
+
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
|
21
|
+
apiKey: '...',
|
|
22
|
+
primaryModel: 'gpt-4o-mini',
|
|
23
|
+
pushSubscription: subscription.toJSON(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// service-worker.js — 用 isContentPush 在收到推送时收窄类型
|
|
27
|
+
import { isContentPush } from '@rei-standard/amsg-client';
|
|
28
|
+
|
|
29
|
+
self.addEventListener('push', (event) => {
|
|
30
|
+
const payload = event.data?.json();
|
|
31
|
+
if (isContentPush(payload)) {
|
|
32
|
+
// payload 已被收窄为 ContentPush —— 安全读取 payload.message
|
|
33
|
+
event.waitUntil(
|
|
34
|
+
self.registration.showNotification(payload.contactName ?? 'Rei', {
|
|
35
|
+
body: payload.message,
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
```
|
|
5
41
|
|
|
6
42
|
## 安装
|
|
7
43
|
|
|
@@ -116,6 +152,61 @@ await client.sendInstant({
|
|
|
116
152
|
|
|
117
153
|
注意 `completePrompt` 和 `messages` **必须恰好二选一**——两者同时给会被 Worker / Server 端返回 `400 INVALID_PAYLOAD_FORMAT` / `INVALID_PARAMETERS`。`scheduleMessage` 也接受同样的 `messages` 字段(amsg-server 2.2.0+ 起持久化层一并支持),用法相同。
|
|
118
154
|
|
|
155
|
+
### `splitPattern` 自定义分句正则(对接 amsg-instant 0.6.0+ / amsg-server 2.3.0+)
|
|
156
|
+
|
|
157
|
+
LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送。要换成别的正则(按换行、按段落、自定义符号……)就在 payload 里加 `splitPattern`:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
// 单正则:按换行切
|
|
161
|
+
await client.sendInstant({
|
|
162
|
+
contactName: 'Rei',
|
|
163
|
+
completePrompt: '...',
|
|
164
|
+
splitPattern: '([\\n]+)',
|
|
165
|
+
// 其余字段同上
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 数组级联:先按段落,每段再按句号
|
|
169
|
+
await client.sendInstant({
|
|
170
|
+
contactName: 'Rei',
|
|
171
|
+
completePrompt: '...',
|
|
172
|
+
splitPattern: ['(\\n\\n+)', '([。!?!?]+)'],
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`splitPattern` 类型是 `string | string[]`。`scheduleMessage` 也支持,`updateMessage` 可显式传 `splitPattern: null` 重置回默认。client SDK 完全透传不校验,所有错误在 Worker / Server 端返回(每项 ≤ 200 字符、数组 ≤ 10 项、必须能 `new RegExp()` 通过)。
|
|
177
|
+
|
|
178
|
+
**两个常见 footgun**:
|
|
179
|
+
|
|
180
|
+
- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
|
|
181
|
+
- 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
|
|
182
|
+
|
|
183
|
+
### 本地预校验:`avatarUrl` 与 payload 体积(2.2.3+)
|
|
184
|
+
|
|
185
|
+
`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项预检,避免一次远端往返才拿到 `413` 或 Web Push 4KB 上限报错:
|
|
186
|
+
|
|
187
|
+
| 触发条件 | 抛出 `Error.code` | 触发原因(背景说明,不在 message 里) |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `INVALID_AVATAR_URL_LOCAL` | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 |
|
|
190
|
+
| `payload.avatarUrl` 长度 > 2048 字符 | `INVALID_AVATAR_URL_LOCAL` | 同上。建议用 CDN 缩略图 URL。 |
|
|
191
|
+
| `payload.avatarUrl` 不是字符串 | `INVALID_AVATAR_URL_LOCAL` | 类型错误。 |
|
|
192
|
+
| `JSON.stringify(payload)` UTF-8 字节数 > 3072 | `PAYLOAD_TOO_LARGE_LOCAL` | 远端网关 / Web Push 4KB 硬上限的本地兜底。错误对象带 `.details = { method, actualBytes, limitBytes }` 方便定位。 |
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
try {
|
|
196
|
+
await client.sendInstant(payload);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err.code === 'INVALID_AVATAR_URL_LOCAL') {
|
|
199
|
+
// err.message 形如「头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL」
|
|
200
|
+
} else if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
|
|
201
|
+
// err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 }
|
|
202
|
+
} else {
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
服务端(`@rei-standard/amsg-instant` 0.6.1+ / `@rei-standard/amsg-server` 2.3.1+)有等价的二道防线,业务可以放心依赖 client 这一道做 UX 提示。
|
|
209
|
+
|
|
119
210
|
## 导出 API(Exports)
|
|
120
211
|
|
|
121
212
|
- `ReiClient`
|
package/dist/index.cjs
CHANGED
|
@@ -19,9 +19,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
-
|
|
22
|
+
MESSAGE_KIND: () => import_amsg_shared.MESSAGE_KIND,
|
|
23
|
+
MESSAGE_TYPE: () => import_amsg_shared.MESSAGE_TYPE,
|
|
24
|
+
PUSH_SOURCE: () => import_amsg_shared.PUSH_SOURCE,
|
|
25
|
+
ReiClient: () => ReiClient,
|
|
26
|
+
buildContentPush: () => import_amsg_shared.buildContentPush,
|
|
27
|
+
buildErrorPush: () => import_amsg_shared.buildErrorPush,
|
|
28
|
+
buildReasoningPush: () => import_amsg_shared.buildReasoningPush,
|
|
29
|
+
buildToolRequestPush: () => import_amsg_shared.buildToolRequestPush,
|
|
30
|
+
isContentPush: () => import_amsg_shared.isContentPush,
|
|
31
|
+
isErrorPush: () => import_amsg_shared.isErrorPush,
|
|
32
|
+
isReasoningPush: () => import_amsg_shared.isReasoningPush,
|
|
33
|
+
isToolRequestPush: () => import_amsg_shared.isToolRequestPush
|
|
23
34
|
});
|
|
24
35
|
module.exports = __toCommonJS(src_exports);
|
|
36
|
+
var import_amsg_shared = require("@rei-standard/amsg-shared");
|
|
37
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
38
|
+
var PAYLOAD_LOCAL_MAX_BYTES = 3072;
|
|
39
|
+
function makeLocalError(code, message, details) {
|
|
40
|
+
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
41
|
+
err.code = code;
|
|
42
|
+
if (details) err.details = details;
|
|
43
|
+
return err;
|
|
44
|
+
}
|
|
25
45
|
var ReiClient = class {
|
|
26
46
|
/**
|
|
27
47
|
* @param {ReiClientConfig} config
|
|
@@ -97,11 +117,19 @@ var ReiClient = class {
|
|
|
97
117
|
*
|
|
98
118
|
* The payload is automatically encrypted before transmission.
|
|
99
119
|
*
|
|
120
|
+
* Throws (without a network round-trip):
|
|
121
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
122
|
+
* or otherwise unacceptable.
|
|
123
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
124
|
+
*
|
|
100
125
|
* @param {Object} payload - Schedule message payload.
|
|
101
126
|
* @returns {Promise<Object>} API response body.
|
|
102
127
|
*/
|
|
103
128
|
async scheduleMessage(payload) {
|
|
104
|
-
|
|
129
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
130
|
+
const json = JSON.stringify(payload);
|
|
131
|
+
this._assertPayloadSize(json, "scheduleMessage");
|
|
132
|
+
const encrypted = await this._encrypt(json);
|
|
105
133
|
const res = await fetch(`${this._baseUrl}/schedule-message`, {
|
|
106
134
|
method: "POST",
|
|
107
135
|
headers: {
|
|
@@ -135,21 +163,29 @@ var ReiClient = class {
|
|
|
135
163
|
*
|
|
136
164
|
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
137
165
|
*
|
|
166
|
+
* Throws (without a network round-trip):
|
|
167
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
168
|
+
* or otherwise unacceptable.
|
|
169
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
170
|
+
*
|
|
138
171
|
* @param {Object} payload - Instant message payload.
|
|
139
172
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
140
173
|
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
141
174
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
142
175
|
*/
|
|
143
176
|
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
177
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
178
|
+
const json = JSON.stringify(payload);
|
|
179
|
+
this._assertPayloadSize(json, "sendInstant");
|
|
144
180
|
const headers = { "Content-Type": "application/json" };
|
|
145
181
|
let body;
|
|
146
182
|
if (this._instantEncryption === false) {
|
|
147
|
-
body =
|
|
183
|
+
body = json;
|
|
148
184
|
if (this._instantClientToken) {
|
|
149
185
|
headers["X-Client-Token"] = this._instantClientToken;
|
|
150
186
|
}
|
|
151
187
|
} else {
|
|
152
|
-
const encrypted = await this._encrypt(
|
|
188
|
+
const encrypted = await this._encrypt(json);
|
|
153
189
|
headers["X-User-Id"] = this._userId;
|
|
154
190
|
headers["X-Payload-Encrypted"] = "true";
|
|
155
191
|
headers["X-Encryption-Version"] = "1";
|
|
@@ -169,12 +205,20 @@ var ReiClient = class {
|
|
|
169
205
|
/**
|
|
170
206
|
* Update an existing scheduled message.
|
|
171
207
|
*
|
|
208
|
+
* Throws (without a network round-trip):
|
|
209
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `updates.avatarUrl` is a `data:` URI,
|
|
210
|
+
* > 2 KB, or otherwise unacceptable.
|
|
211
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized updates exceed 3 KB.
|
|
212
|
+
*
|
|
172
213
|
* @param {string} uuid - Task UUID.
|
|
173
214
|
* @param {Object} updates - Fields to update.
|
|
174
215
|
* @returns {Promise<Object>}
|
|
175
216
|
*/
|
|
176
217
|
async updateMessage(uuid, updates) {
|
|
177
|
-
|
|
218
|
+
this._validateAvatarUrl(updates && updates.avatarUrl);
|
|
219
|
+
const json = JSON.stringify(updates);
|
|
220
|
+
this._assertPayloadSize(json, "updateMessage");
|
|
221
|
+
const encrypted = await this._encrypt(json);
|
|
178
222
|
const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
|
|
179
223
|
method: "PUT",
|
|
180
224
|
headers: {
|
|
@@ -249,6 +293,55 @@ var ReiClient = class {
|
|
|
249
293
|
});
|
|
250
294
|
return subscription;
|
|
251
295
|
}
|
|
296
|
+
// ─── Local preflight (no network) ────────────────────────────────
|
|
297
|
+
/**
|
|
298
|
+
* Reject `avatarUrl` values that would 100% fail downstream — `data:`
|
|
299
|
+
* URIs (base64 inline image) and anything longer than 2 KB. Mirrors the
|
|
300
|
+
* server-side check in `@rei-standard/amsg-instant` / `@rei-standard/amsg-server`;
|
|
301
|
+
* intentionally a fast preflight that does not parse the URL (server
|
|
302
|
+
* still does that, and `URL` is the bigger of the two costs in browsers).
|
|
303
|
+
*
|
|
304
|
+
* @private
|
|
305
|
+
* @param {unknown} value
|
|
306
|
+
*/
|
|
307
|
+
_validateAvatarUrl(value) {
|
|
308
|
+
if (value === void 0 || value === null) return;
|
|
309
|
+
if (typeof value !== "string") {
|
|
310
|
+
throw makeLocalError("INVALID_AVATAR_URL_LOCAL", "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
|
|
311
|
+
}
|
|
312
|
+
if (/^data:/i.test(value)) {
|
|
313
|
+
throw makeLocalError(
|
|
314
|
+
"INVALID_AVATAR_URL_LOCAL",
|
|
315
|
+
"\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
319
|
+
throw makeLocalError(
|
|
320
|
+
"INVALID_AVATAR_URL_LOCAL",
|
|
321
|
+
`\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`,
|
|
322
|
+
{ actualLength: value.length, limit: AVATAR_URL_MAX_LENGTH }
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
|
|
328
|
+
* remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
|
|
329
|
+
* caller a precise local error pointing at the size cap.
|
|
330
|
+
*
|
|
331
|
+
* @private
|
|
332
|
+
* @param {string} bodyJson - `JSON.stringify(payload)`.
|
|
333
|
+
* @param {string} methodName
|
|
334
|
+
*/
|
|
335
|
+
_assertPayloadSize(bodyJson, methodName) {
|
|
336
|
+
const bytes = new TextEncoder().encode(bodyJson).length;
|
|
337
|
+
if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
|
|
338
|
+
throw makeLocalError(
|
|
339
|
+
"PAYLOAD_TOO_LARGE_LOCAL",
|
|
340
|
+
`${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${PAYLOAD_LOCAL_MAX_BYTES} \u5B57\u8282`,
|
|
341
|
+
{ method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
252
345
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
253
346
|
/**
|
|
254
347
|
* Encrypt plaintext with AES-256-GCM.
|
package/dist/index.d.cts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* ReiStandard Client SDK
|
|
3
5
|
*
|
|
@@ -22,6 +24,15 @@
|
|
|
22
24
|
* await client.scheduleMessage({ ... });
|
|
23
25
|
*/
|
|
24
26
|
|
|
27
|
+
/** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */
|
|
28
|
+
/** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */
|
|
29
|
+
/** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */
|
|
30
|
+
/** @typedef {import('@rei-standard/amsg-shared').AmsgPush} AmsgPush */
|
|
31
|
+
/** @typedef {import('@rei-standard/amsg-shared').ContentPush} ContentPush */
|
|
32
|
+
/** @typedef {import('@rei-standard/amsg-shared').ReasoningPush} ReasoningPush */
|
|
33
|
+
/** @typedef {import('@rei-standard/amsg-shared').ToolRequestPush} ToolRequestPush */
|
|
34
|
+
/** @typedef {import('@rei-standard/amsg-shared').ErrorPush} ErrorPush */
|
|
35
|
+
|
|
25
36
|
/**
|
|
26
37
|
* @typedef {Object} ReiClientConfig
|
|
27
38
|
* @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
|
|
@@ -52,6 +63,30 @@
|
|
|
52
63
|
* read it. Use for casual URL-direct abuse only.
|
|
53
64
|
*/
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors
|
|
68
|
+
* `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side
|
|
69
|
+
* limits — kept in lockstep on purpose so client-side rejects match what
|
|
70
|
+
* the server would reject.
|
|
71
|
+
*/
|
|
72
|
+
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Max byte length of a single outgoing payload (3 KB, measured pre-encryption
|
|
76
|
+
* on the plaintext JSON body). Anything over this is almost certainly a base64
|
|
77
|
+
* avatar smuggled into `avatarUrl` and will trigger downstream `413 Payload
|
|
78
|
+
* Too Large` or hit the Web Push 4 KB hard limit at delivery. We bail locally
|
|
79
|
+
* to save a remote round-trip and give a precise error.
|
|
80
|
+
*/
|
|
81
|
+
const PAYLOAD_LOCAL_MAX_BYTES = 3072;
|
|
82
|
+
|
|
83
|
+
function makeLocalError(code, message, details) {
|
|
84
|
+
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
85
|
+
err.code = code;
|
|
86
|
+
if (details) err.details = details;
|
|
87
|
+
return err;
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
class ReiClient {
|
|
56
91
|
/**
|
|
57
92
|
* @param {ReiClientConfig} config
|
|
@@ -146,11 +181,19 @@ class ReiClient {
|
|
|
146
181
|
*
|
|
147
182
|
* The payload is automatically encrypted before transmission.
|
|
148
183
|
*
|
|
184
|
+
* Throws (without a network round-trip):
|
|
185
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
186
|
+
* or otherwise unacceptable.
|
|
187
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
188
|
+
*
|
|
149
189
|
* @param {Object} payload - Schedule message payload.
|
|
150
190
|
* @returns {Promise<Object>} API response body.
|
|
151
191
|
*/
|
|
152
192
|
async scheduleMessage(payload) {
|
|
153
|
-
|
|
193
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
194
|
+
const json = JSON.stringify(payload);
|
|
195
|
+
this._assertPayloadSize(json, 'scheduleMessage');
|
|
196
|
+
const encrypted = await this._encrypt(json);
|
|
154
197
|
|
|
155
198
|
const res = await fetch(`${this._baseUrl}/schedule-message`, {
|
|
156
199
|
method: 'POST',
|
|
@@ -187,22 +230,31 @@ class ReiClient {
|
|
|
187
230
|
*
|
|
188
231
|
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
189
232
|
*
|
|
233
|
+
* Throws (without a network round-trip):
|
|
234
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
235
|
+
* or otherwise unacceptable.
|
|
236
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
237
|
+
*
|
|
190
238
|
* @param {Object} payload - Instant message payload.
|
|
191
239
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
192
240
|
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
193
241
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
194
242
|
*/
|
|
195
243
|
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
244
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
245
|
+
const json = JSON.stringify(payload);
|
|
246
|
+
this._assertPayloadSize(json, 'sendInstant');
|
|
247
|
+
|
|
196
248
|
const headers = { 'Content-Type': 'application/json' };
|
|
197
249
|
let body;
|
|
198
250
|
|
|
199
251
|
if (this._instantEncryption === false) {
|
|
200
|
-
body =
|
|
252
|
+
body = json;
|
|
201
253
|
if (this._instantClientToken) {
|
|
202
254
|
headers['X-Client-Token'] = this._instantClientToken;
|
|
203
255
|
}
|
|
204
256
|
} else {
|
|
205
|
-
const encrypted = await this._encrypt(
|
|
257
|
+
const encrypted = await this._encrypt(json);
|
|
206
258
|
headers['X-User-Id'] = this._userId;
|
|
207
259
|
headers['X-Payload-Encrypted'] = 'true';
|
|
208
260
|
headers['X-Encryption-Version'] = '1';
|
|
@@ -226,12 +278,20 @@ class ReiClient {
|
|
|
226
278
|
/**
|
|
227
279
|
* Update an existing scheduled message.
|
|
228
280
|
*
|
|
281
|
+
* Throws (without a network round-trip):
|
|
282
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `updates.avatarUrl` is a `data:` URI,
|
|
283
|
+
* > 2 KB, or otherwise unacceptable.
|
|
284
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized updates exceed 3 KB.
|
|
285
|
+
*
|
|
229
286
|
* @param {string} uuid - Task UUID.
|
|
230
287
|
* @param {Object} updates - Fields to update.
|
|
231
288
|
* @returns {Promise<Object>}
|
|
232
289
|
*/
|
|
233
290
|
async updateMessage(uuid, updates) {
|
|
234
|
-
|
|
291
|
+
this._validateAvatarUrl(updates && updates.avatarUrl);
|
|
292
|
+
const json = JSON.stringify(updates);
|
|
293
|
+
this._assertPayloadSize(json, 'updateMessage');
|
|
294
|
+
const encrypted = await this._encrypt(json);
|
|
235
295
|
|
|
236
296
|
const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
|
|
237
297
|
method: 'PUT',
|
|
@@ -318,6 +378,58 @@ class ReiClient {
|
|
|
318
378
|
return subscription;
|
|
319
379
|
}
|
|
320
380
|
|
|
381
|
+
// ─── Local preflight (no network) ────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Reject `avatarUrl` values that would 100% fail downstream — `data:`
|
|
385
|
+
* URIs (base64 inline image) and anything longer than 2 KB. Mirrors the
|
|
386
|
+
* server-side check in `@rei-standard/amsg-instant` / `@rei-standard/amsg-server`;
|
|
387
|
+
* intentionally a fast preflight that does not parse the URL (server
|
|
388
|
+
* still does that, and `URL` is the bigger of the two costs in browsers).
|
|
389
|
+
*
|
|
390
|
+
* @private
|
|
391
|
+
* @param {unknown} value
|
|
392
|
+
*/
|
|
393
|
+
_validateAvatarUrl(value) {
|
|
394
|
+
if (value === undefined || value === null) return;
|
|
395
|
+
if (typeof value !== 'string') {
|
|
396
|
+
throw makeLocalError('INVALID_AVATAR_URL_LOCAL', 'avatarUrl 必须是字符串');
|
|
397
|
+
}
|
|
398
|
+
if (/^data:/i.test(value)) {
|
|
399
|
+
throw makeLocalError(
|
|
400
|
+
'INVALID_AVATAR_URL_LOCAL',
|
|
401
|
+
'头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
405
|
+
throw makeLocalError(
|
|
406
|
+
'INVALID_AVATAR_URL_LOCAL',
|
|
407
|
+
`头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`,
|
|
408
|
+
{ actualLength: value.length, limit: AVATAR_URL_MAX_LENGTH }
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
|
|
415
|
+
* remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
|
|
416
|
+
* caller a precise local error pointing at the size cap.
|
|
417
|
+
*
|
|
418
|
+
* @private
|
|
419
|
+
* @param {string} bodyJson - `JSON.stringify(payload)`.
|
|
420
|
+
* @param {string} methodName
|
|
421
|
+
*/
|
|
422
|
+
_assertPayloadSize(bodyJson, methodName) {
|
|
423
|
+
const bytes = new TextEncoder().encode(bodyJson).length;
|
|
424
|
+
if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
|
|
425
|
+
throw makeLocalError(
|
|
426
|
+
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
427
|
+
`${methodName} payload 体积 ${bytes} 字节超过本地上限 ${PAYLOAD_LOCAL_MAX_BYTES} 字节`,
|
|
428
|
+
{ method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
321
433
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
322
434
|
|
|
323
435
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* ReiStandard Client SDK
|
|
3
5
|
*
|
|
@@ -22,6 +24,15 @@
|
|
|
22
24
|
* await client.scheduleMessage({ ... });
|
|
23
25
|
*/
|
|
24
26
|
|
|
27
|
+
/** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */
|
|
28
|
+
/** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */
|
|
29
|
+
/** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */
|
|
30
|
+
/** @typedef {import('@rei-standard/amsg-shared').AmsgPush} AmsgPush */
|
|
31
|
+
/** @typedef {import('@rei-standard/amsg-shared').ContentPush} ContentPush */
|
|
32
|
+
/** @typedef {import('@rei-standard/amsg-shared').ReasoningPush} ReasoningPush */
|
|
33
|
+
/** @typedef {import('@rei-standard/amsg-shared').ToolRequestPush} ToolRequestPush */
|
|
34
|
+
/** @typedef {import('@rei-standard/amsg-shared').ErrorPush} ErrorPush */
|
|
35
|
+
|
|
25
36
|
/**
|
|
26
37
|
* @typedef {Object} ReiClientConfig
|
|
27
38
|
* @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
|
|
@@ -52,6 +63,30 @@
|
|
|
52
63
|
* read it. Use for casual URL-direct abuse only.
|
|
53
64
|
*/
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors
|
|
68
|
+
* `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side
|
|
69
|
+
* limits — kept in lockstep on purpose so client-side rejects match what
|
|
70
|
+
* the server would reject.
|
|
71
|
+
*/
|
|
72
|
+
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Max byte length of a single outgoing payload (3 KB, measured pre-encryption
|
|
76
|
+
* on the plaintext JSON body). Anything over this is almost certainly a base64
|
|
77
|
+
* avatar smuggled into `avatarUrl` and will trigger downstream `413 Payload
|
|
78
|
+
* Too Large` or hit the Web Push 4 KB hard limit at delivery. We bail locally
|
|
79
|
+
* to save a remote round-trip and give a precise error.
|
|
80
|
+
*/
|
|
81
|
+
const PAYLOAD_LOCAL_MAX_BYTES = 3072;
|
|
82
|
+
|
|
83
|
+
function makeLocalError(code, message, details) {
|
|
84
|
+
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
85
|
+
err.code = code;
|
|
86
|
+
if (details) err.details = details;
|
|
87
|
+
return err;
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
class ReiClient {
|
|
56
91
|
/**
|
|
57
92
|
* @param {ReiClientConfig} config
|
|
@@ -146,11 +181,19 @@ class ReiClient {
|
|
|
146
181
|
*
|
|
147
182
|
* The payload is automatically encrypted before transmission.
|
|
148
183
|
*
|
|
184
|
+
* Throws (without a network round-trip):
|
|
185
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
186
|
+
* or otherwise unacceptable.
|
|
187
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
188
|
+
*
|
|
149
189
|
* @param {Object} payload - Schedule message payload.
|
|
150
190
|
* @returns {Promise<Object>} API response body.
|
|
151
191
|
*/
|
|
152
192
|
async scheduleMessage(payload) {
|
|
153
|
-
|
|
193
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
194
|
+
const json = JSON.stringify(payload);
|
|
195
|
+
this._assertPayloadSize(json, 'scheduleMessage');
|
|
196
|
+
const encrypted = await this._encrypt(json);
|
|
154
197
|
|
|
155
198
|
const res = await fetch(`${this._baseUrl}/schedule-message`, {
|
|
156
199
|
method: 'POST',
|
|
@@ -187,22 +230,31 @@ class ReiClient {
|
|
|
187
230
|
*
|
|
188
231
|
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
189
232
|
*
|
|
233
|
+
* Throws (without a network round-trip):
|
|
234
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
235
|
+
* or otherwise unacceptable.
|
|
236
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
237
|
+
*
|
|
190
238
|
* @param {Object} payload - Instant message payload.
|
|
191
239
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
192
240
|
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
193
241
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
194
242
|
*/
|
|
195
243
|
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
244
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
245
|
+
const json = JSON.stringify(payload);
|
|
246
|
+
this._assertPayloadSize(json, 'sendInstant');
|
|
247
|
+
|
|
196
248
|
const headers = { 'Content-Type': 'application/json' };
|
|
197
249
|
let body;
|
|
198
250
|
|
|
199
251
|
if (this._instantEncryption === false) {
|
|
200
|
-
body =
|
|
252
|
+
body = json;
|
|
201
253
|
if (this._instantClientToken) {
|
|
202
254
|
headers['X-Client-Token'] = this._instantClientToken;
|
|
203
255
|
}
|
|
204
256
|
} else {
|
|
205
|
-
const encrypted = await this._encrypt(
|
|
257
|
+
const encrypted = await this._encrypt(json);
|
|
206
258
|
headers['X-User-Id'] = this._userId;
|
|
207
259
|
headers['X-Payload-Encrypted'] = 'true';
|
|
208
260
|
headers['X-Encryption-Version'] = '1';
|
|
@@ -226,12 +278,20 @@ class ReiClient {
|
|
|
226
278
|
/**
|
|
227
279
|
* Update an existing scheduled message.
|
|
228
280
|
*
|
|
281
|
+
* Throws (without a network round-trip):
|
|
282
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `updates.avatarUrl` is a `data:` URI,
|
|
283
|
+
* > 2 KB, or otherwise unacceptable.
|
|
284
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized updates exceed 3 KB.
|
|
285
|
+
*
|
|
229
286
|
* @param {string} uuid - Task UUID.
|
|
230
287
|
* @param {Object} updates - Fields to update.
|
|
231
288
|
* @returns {Promise<Object>}
|
|
232
289
|
*/
|
|
233
290
|
async updateMessage(uuid, updates) {
|
|
234
|
-
|
|
291
|
+
this._validateAvatarUrl(updates && updates.avatarUrl);
|
|
292
|
+
const json = JSON.stringify(updates);
|
|
293
|
+
this._assertPayloadSize(json, 'updateMessage');
|
|
294
|
+
const encrypted = await this._encrypt(json);
|
|
235
295
|
|
|
236
296
|
const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
|
|
237
297
|
method: 'PUT',
|
|
@@ -318,6 +378,58 @@ class ReiClient {
|
|
|
318
378
|
return subscription;
|
|
319
379
|
}
|
|
320
380
|
|
|
381
|
+
// ─── Local preflight (no network) ────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Reject `avatarUrl` values that would 100% fail downstream — `data:`
|
|
385
|
+
* URIs (base64 inline image) and anything longer than 2 KB. Mirrors the
|
|
386
|
+
* server-side check in `@rei-standard/amsg-instant` / `@rei-standard/amsg-server`;
|
|
387
|
+
* intentionally a fast preflight that does not parse the URL (server
|
|
388
|
+
* still does that, and `URL` is the bigger of the two costs in browsers).
|
|
389
|
+
*
|
|
390
|
+
* @private
|
|
391
|
+
* @param {unknown} value
|
|
392
|
+
*/
|
|
393
|
+
_validateAvatarUrl(value) {
|
|
394
|
+
if (value === undefined || value === null) return;
|
|
395
|
+
if (typeof value !== 'string') {
|
|
396
|
+
throw makeLocalError('INVALID_AVATAR_URL_LOCAL', 'avatarUrl 必须是字符串');
|
|
397
|
+
}
|
|
398
|
+
if (/^data:/i.test(value)) {
|
|
399
|
+
throw makeLocalError(
|
|
400
|
+
'INVALID_AVATAR_URL_LOCAL',
|
|
401
|
+
'头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
405
|
+
throw makeLocalError(
|
|
406
|
+
'INVALID_AVATAR_URL_LOCAL',
|
|
407
|
+
`头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`,
|
|
408
|
+
{ actualLength: value.length, limit: AVATAR_URL_MAX_LENGTH }
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
|
|
415
|
+
* remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
|
|
416
|
+
* caller a precise local error pointing at the size cap.
|
|
417
|
+
*
|
|
418
|
+
* @private
|
|
419
|
+
* @param {string} bodyJson - `JSON.stringify(payload)`.
|
|
420
|
+
* @param {string} methodName
|
|
421
|
+
*/
|
|
422
|
+
_assertPayloadSize(bodyJson, methodName) {
|
|
423
|
+
const bytes = new TextEncoder().encode(bodyJson).length;
|
|
424
|
+
if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
|
|
425
|
+
throw makeLocalError(
|
|
426
|
+
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
427
|
+
`${methodName} payload 体积 ${bytes} 字节超过本地上限 ${PAYLOAD_LOCAL_MAX_BYTES} 字节`,
|
|
428
|
+
{ method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
321
433
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
322
434
|
|
|
323
435
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
// src/index.js
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_KIND,
|
|
4
|
+
MESSAGE_TYPE,
|
|
5
|
+
PUSH_SOURCE,
|
|
6
|
+
buildContentPush,
|
|
7
|
+
buildReasoningPush,
|
|
8
|
+
buildToolRequestPush,
|
|
9
|
+
buildErrorPush,
|
|
10
|
+
isContentPush,
|
|
11
|
+
isReasoningPush,
|
|
12
|
+
isToolRequestPush,
|
|
13
|
+
isErrorPush
|
|
14
|
+
} from "@rei-standard/amsg-shared";
|
|
15
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
16
|
+
var PAYLOAD_LOCAL_MAX_BYTES = 3072;
|
|
17
|
+
function makeLocalError(code, message, details) {
|
|
18
|
+
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
19
|
+
err.code = code;
|
|
20
|
+
if (details) err.details = details;
|
|
21
|
+
return err;
|
|
22
|
+
}
|
|
2
23
|
var ReiClient = class {
|
|
3
24
|
/**
|
|
4
25
|
* @param {ReiClientConfig} config
|
|
@@ -74,11 +95,19 @@ var ReiClient = class {
|
|
|
74
95
|
*
|
|
75
96
|
* The payload is automatically encrypted before transmission.
|
|
76
97
|
*
|
|
98
|
+
* Throws (without a network round-trip):
|
|
99
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
100
|
+
* or otherwise unacceptable.
|
|
101
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
102
|
+
*
|
|
77
103
|
* @param {Object} payload - Schedule message payload.
|
|
78
104
|
* @returns {Promise<Object>} API response body.
|
|
79
105
|
*/
|
|
80
106
|
async scheduleMessage(payload) {
|
|
81
|
-
|
|
107
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
108
|
+
const json = JSON.stringify(payload);
|
|
109
|
+
this._assertPayloadSize(json, "scheduleMessage");
|
|
110
|
+
const encrypted = await this._encrypt(json);
|
|
82
111
|
const res = await fetch(`${this._baseUrl}/schedule-message`, {
|
|
83
112
|
method: "POST",
|
|
84
113
|
headers: {
|
|
@@ -112,21 +141,29 @@ var ReiClient = class {
|
|
|
112
141
|
*
|
|
113
142
|
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
114
143
|
*
|
|
144
|
+
* Throws (without a network round-trip):
|
|
145
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB,
|
|
146
|
+
* or otherwise unacceptable.
|
|
147
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
|
|
148
|
+
*
|
|
115
149
|
* @param {Object} payload - Instant message payload.
|
|
116
150
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
117
151
|
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
118
152
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
119
153
|
*/
|
|
120
154
|
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
155
|
+
this._validateAvatarUrl(payload && payload.avatarUrl);
|
|
156
|
+
const json = JSON.stringify(payload);
|
|
157
|
+
this._assertPayloadSize(json, "sendInstant");
|
|
121
158
|
const headers = { "Content-Type": "application/json" };
|
|
122
159
|
let body;
|
|
123
160
|
if (this._instantEncryption === false) {
|
|
124
|
-
body =
|
|
161
|
+
body = json;
|
|
125
162
|
if (this._instantClientToken) {
|
|
126
163
|
headers["X-Client-Token"] = this._instantClientToken;
|
|
127
164
|
}
|
|
128
165
|
} else {
|
|
129
|
-
const encrypted = await this._encrypt(
|
|
166
|
+
const encrypted = await this._encrypt(json);
|
|
130
167
|
headers["X-User-Id"] = this._userId;
|
|
131
168
|
headers["X-Payload-Encrypted"] = "true";
|
|
132
169
|
headers["X-Encryption-Version"] = "1";
|
|
@@ -146,12 +183,20 @@ var ReiClient = class {
|
|
|
146
183
|
/**
|
|
147
184
|
* Update an existing scheduled message.
|
|
148
185
|
*
|
|
186
|
+
* Throws (without a network round-trip):
|
|
187
|
+
* - `INVALID_AVATAR_URL_LOCAL` — `updates.avatarUrl` is a `data:` URI,
|
|
188
|
+
* > 2 KB, or otherwise unacceptable.
|
|
189
|
+
* - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized updates exceed 3 KB.
|
|
190
|
+
*
|
|
149
191
|
* @param {string} uuid - Task UUID.
|
|
150
192
|
* @param {Object} updates - Fields to update.
|
|
151
193
|
* @returns {Promise<Object>}
|
|
152
194
|
*/
|
|
153
195
|
async updateMessage(uuid, updates) {
|
|
154
|
-
|
|
196
|
+
this._validateAvatarUrl(updates && updates.avatarUrl);
|
|
197
|
+
const json = JSON.stringify(updates);
|
|
198
|
+
this._assertPayloadSize(json, "updateMessage");
|
|
199
|
+
const encrypted = await this._encrypt(json);
|
|
155
200
|
const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
|
|
156
201
|
method: "PUT",
|
|
157
202
|
headers: {
|
|
@@ -226,6 +271,55 @@ var ReiClient = class {
|
|
|
226
271
|
});
|
|
227
272
|
return subscription;
|
|
228
273
|
}
|
|
274
|
+
// ─── Local preflight (no network) ────────────────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* Reject `avatarUrl` values that would 100% fail downstream — `data:`
|
|
277
|
+
* URIs (base64 inline image) and anything longer than 2 KB. Mirrors the
|
|
278
|
+
* server-side check in `@rei-standard/amsg-instant` / `@rei-standard/amsg-server`;
|
|
279
|
+
* intentionally a fast preflight that does not parse the URL (server
|
|
280
|
+
* still does that, and `URL` is the bigger of the two costs in browsers).
|
|
281
|
+
*
|
|
282
|
+
* @private
|
|
283
|
+
* @param {unknown} value
|
|
284
|
+
*/
|
|
285
|
+
_validateAvatarUrl(value) {
|
|
286
|
+
if (value === void 0 || value === null) return;
|
|
287
|
+
if (typeof value !== "string") {
|
|
288
|
+
throw makeLocalError("INVALID_AVATAR_URL_LOCAL", "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
|
|
289
|
+
}
|
|
290
|
+
if (/^data:/i.test(value)) {
|
|
291
|
+
throw makeLocalError(
|
|
292
|
+
"INVALID_AVATAR_URL_LOCAL",
|
|
293
|
+
"\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
297
|
+
throw makeLocalError(
|
|
298
|
+
"INVALID_AVATAR_URL_LOCAL",
|
|
299
|
+
`\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`,
|
|
300
|
+
{ actualLength: value.length, limit: AVATAR_URL_MAX_LENGTH }
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
|
|
306
|
+
* remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
|
|
307
|
+
* caller a precise local error pointing at the size cap.
|
|
308
|
+
*
|
|
309
|
+
* @private
|
|
310
|
+
* @param {string} bodyJson - `JSON.stringify(payload)`.
|
|
311
|
+
* @param {string} methodName
|
|
312
|
+
*/
|
|
313
|
+
_assertPayloadSize(bodyJson, methodName) {
|
|
314
|
+
const bytes = new TextEncoder().encode(bodyJson).length;
|
|
315
|
+
if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
|
|
316
|
+
throw makeLocalError(
|
|
317
|
+
"PAYLOAD_TOO_LARGE_LOCAL",
|
|
318
|
+
`${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${PAYLOAD_LOCAL_MAX_BYTES} \u5B57\u8282`,
|
|
319
|
+
{ method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
229
323
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
230
324
|
/**
|
|
231
325
|
* Encrypt plaintext with AES-256-GCM.
|
|
@@ -301,5 +395,16 @@ var ReiClient = class {
|
|
|
301
395
|
}
|
|
302
396
|
};
|
|
303
397
|
export {
|
|
304
|
-
|
|
398
|
+
MESSAGE_KIND,
|
|
399
|
+
MESSAGE_TYPE,
|
|
400
|
+
PUSH_SOURCE,
|
|
401
|
+
ReiClient,
|
|
402
|
+
buildContentPush,
|
|
403
|
+
buildErrorPush,
|
|
404
|
+
buildReasoningPush,
|
|
405
|
+
buildToolRequestPush,
|
|
406
|
+
isContentPush,
|
|
407
|
+
isErrorPush,
|
|
408
|
+
isReasoningPush,
|
|
409
|
+
isToolRequestPush
|
|
305
410
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rei-standard/amsg-client",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "ReiStandard Active Messaging browser client SDK",
|
|
3
|
+
"version": "2.3.0-next.0",
|
|
4
|
+
"description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/Tosd0/ReiStandard",
|
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
"dist"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "tsup"
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"test": "node --test test/*.test.mjs"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=20"
|
|
33
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@rei-standard/amsg-shared": "0.1.0-next.0"
|
|
37
|
+
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"tsup": "^8.0.0",
|
|
36
40
|
"typescript": "^5.0.0"
|