@rei-standard/amsg-client 2.2.1 → 2.2.3

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