@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 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
- ReiClient: () => ReiClient
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
- const encrypted = await this._encrypt(JSON.stringify(payload));
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 = JSON.stringify(payload);
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(JSON.stringify(payload));
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
- const encrypted = await this._encrypt(JSON.stringify(updates));
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
- const encrypted = await this._encrypt(JSON.stringify(payload));
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 = JSON.stringify(payload);
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(JSON.stringify(payload));
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
- const encrypted = await this._encrypt(JSON.stringify(updates));
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
- const encrypted = await this._encrypt(JSON.stringify(payload));
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 = JSON.stringify(payload);
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(JSON.stringify(payload));
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
- const encrypted = await this._encrypt(JSON.stringify(updates));
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
- const encrypted = await this._encrypt(JSON.stringify(payload));
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 = JSON.stringify(payload);
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(JSON.stringify(payload));
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
- const encrypted = await this._encrypt(JSON.stringify(updates));
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
- ReiClient
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.2.1",
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"