@rei-standard/amsg-client 2.3.0-next.0 → 2.3.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,24 +180,24 @@ await client.sendInstant({
180
180
  - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
181
181
  - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
182
182
 
183
- ### 本地预校验:`avatarUrl` 与 payload 体积(2.2.3+)
183
+ ### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0-next.1+)
184
184
 
185
- `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项预检,避免一次远端往返才拿到 `413` 或 Web Push 4KB 上限报错:
185
+ `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护:
186
186
 
187
- | 触发条件 | 抛出 `Error.code` | 触发原因(背景说明,不在 message 里) |
187
+ | 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) |
188
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 }` 方便定位。 |
189
+ | `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`,请求照发(`updateMessage` 从 patch 里删除该字段,保留服务端原头像) | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 |
190
+ | `payload.avatarUrl` 长度 > 2048 字符 | 同上 | 同上。建议用 CDN 缩略图 URL。 |
191
+ | `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 |
192
+ | `JSON.stringify(payload)` UTF-8 字节数 > 3072 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 远端网关 / Web Push 4KB 硬上限的本地兜底。 |
193
+
194
+ 头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。`PAYLOAD_TOO_LARGE_LOCAL` 仍然是真正的"整包过大"信号,照常用 try/catch 捕获:
193
195
 
194
196
  ```js
195
197
  try {
196
198
  await client.sendInstant(payload);
197
199
  } 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') {
200
+ if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
201
201
  // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 }
202
202
  } else {
203
203
  throw err;
@@ -205,7 +205,7 @@ try {
205
205
  }
206
206
  ```
207
207
 
208
- 服务端(`@rei-standard/amsg-instant` 0.6.1+ / `@rei-standard/amsg-server` 2.3.1+)有等价的二道防线,业务可以放心依赖 client 这一道做 UX 提示。
208
+ 服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0-next.1+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0-next.1+)有同样的软清空二道防线,client 这一道主要省一次远端往返。
209
209
 
210
210
  ## 导出 API(Exports)
211
211
 
package/dist/index.cjs CHANGED
@@ -117,16 +117,16 @@ var ReiClient = class {
117
117
  *
118
118
  * The payload is automatically encrypted before transmission.
119
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.
120
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
121
+ * client soft-strips it on the payload and emits a `console.warn` the
122
+ * schedule still ships, just without an avatar. The only throw left is
123
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
124
124
  *
125
125
  * @param {Object} payload - Schedule message payload.
126
126
  * @returns {Promise<Object>} API response body.
127
127
  */
128
128
  async scheduleMessage(payload) {
129
- this._validateAvatarUrl(payload && payload.avatarUrl);
129
+ this._sanitizeAvatarUrl(payload);
130
130
  const json = JSON.stringify(payload);
131
131
  this._assertPayloadSize(json, "scheduleMessage");
132
132
  const encrypted = await this._encrypt(json);
@@ -163,10 +163,10 @@ var ReiClient = class {
163
163
  *
164
164
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
165
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.
166
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
167
+ * client soft-strips it on the payload and emits a `console.warn` the
168
+ * push still ships, just without an icon. The only throw left is
169
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
170
170
  *
171
171
  * @param {Object} payload - Instant message payload.
172
172
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -174,7 +174,7 @@ var ReiClient = class {
174
174
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
175
175
  */
176
176
  async sendInstant(payload, endpointPath = "/instant", opts = {}) {
177
- this._validateAvatarUrl(payload && payload.avatarUrl);
177
+ this._sanitizeAvatarUrl(payload);
178
178
  const json = JSON.stringify(payload);
179
179
  this._assertPayloadSize(json, "sendInstant");
180
180
  const headers = { "Content-Type": "application/json" };
@@ -205,17 +205,20 @@ var ReiClient = class {
205
205
  /**
206
206
  * Update an existing scheduled message.
207
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.
208
+ * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
209
+ * the client soft-strips it from the patch and emits a `console.warn`
210
+ * the rest of the update still applies, and the stored avatar is left
211
+ * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` —
212
+ * JSON-serialized updates exceed 3 KB.
212
213
  *
213
214
  * @param {string} uuid - Task UUID.
214
215
  * @param {Object} updates - Fields to update.
215
216
  * @returns {Promise<Object>}
216
217
  */
217
218
  async updateMessage(uuid, updates) {
218
- this._validateAvatarUrl(updates && updates.avatarUrl);
219
+ if (this._sanitizeAvatarUrl(updates)) {
220
+ delete updates.avatarUrl;
221
+ }
219
222
  const json = JSON.stringify(updates);
220
223
  this._assertPayloadSize(json, "updateMessage");
221
224
  const encrypted = await this._encrypt(json);
@@ -295,33 +298,36 @@ var ReiClient = class {
295
298
  }
296
299
  // ─── Local preflight (no network) ────────────────────────────────
297
300
  /**
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).
301
+ * Sanitize `avatarUrl` on an outgoing payload. If the value is unusable
302
+ * (`data:` URI / oversized / non-string), set the field to `null` on the
303
+ * payload, log a `console.warn`, and let the rest of the request go
304
+ * through. Avatar is cosmetic failing the entire schedule / instant
305
+ * call over a bad image URL is too punishing. Mirrors the server-side
306
+ * soft-strip in `@rei-standard/amsg-server` 2.3.3+ and `@rei-standard/amsg-instant`
307
+ * 0.7.1+. See standards §6.2.
303
308
  *
304
309
  * @private
305
- * @param {unknown} value
310
+ * @param {object|null|undefined} target - Payload-like object holding `avatarUrl`.
311
+ * @returns {boolean} `true` if the field was stripped, `false` otherwise.
306
312
  */
307
- _validateAvatarUrl(value) {
308
- if (value === void 0 || value === null) return;
313
+ _sanitizeAvatarUrl(target) {
314
+ if (!target || typeof target !== "object") return false;
315
+ const value = target.avatarUrl;
316
+ if (value === void 0 || value === null) return false;
317
+ let reason = null;
309
318
  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
- );
319
+ reason = "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
320
+ } else if (/^data:/i.test(value)) {
321
+ reason = "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
322
+ } else if (value.length > AVATAR_URL_MAX_LENGTH) {
323
+ reason = `\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`;
317
324
  }
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
- );
325
+ if (reason) {
326
+ console.warn("[rei-standard-amsg-client] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", reason);
327
+ target.avatarUrl = null;
328
+ return true;
324
329
  }
330
+ return false;
325
331
  }
326
332
  /**
327
333
  * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
package/dist/index.d.cts CHANGED
@@ -181,16 +181,16 @@ class ReiClient {
181
181
  *
182
182
  * The payload is automatically encrypted before transmission.
183
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.
184
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
185
+ * client soft-strips it on the payload and emits a `console.warn` the
186
+ * schedule still ships, just without an avatar. The only throw left is
187
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
188
188
  *
189
189
  * @param {Object} payload - Schedule message payload.
190
190
  * @returns {Promise<Object>} API response body.
191
191
  */
192
192
  async scheduleMessage(payload) {
193
- this._validateAvatarUrl(payload && payload.avatarUrl);
193
+ this._sanitizeAvatarUrl(payload);
194
194
  const json = JSON.stringify(payload);
195
195
  this._assertPayloadSize(json, 'scheduleMessage');
196
196
  const encrypted = await this._encrypt(json);
@@ -230,10 +230,10 @@ class ReiClient {
230
230
  *
231
231
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
232
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.
233
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
234
+ * client soft-strips it on the payload and emits a `console.warn` the
235
+ * push still ships, just without an icon. The only throw left is
236
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
237
237
  *
238
238
  * @param {Object} payload - Instant message payload.
239
239
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -241,7 +241,7 @@ class ReiClient {
241
241
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
242
242
  */
243
243
  async sendInstant(payload, endpointPath = '/instant', opts = {}) {
244
- this._validateAvatarUrl(payload && payload.avatarUrl);
244
+ this._sanitizeAvatarUrl(payload);
245
245
  const json = JSON.stringify(payload);
246
246
  this._assertPayloadSize(json, 'sendInstant');
247
247
 
@@ -278,17 +278,23 @@ class ReiClient {
278
278
  /**
279
279
  * Update an existing scheduled message.
280
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.
281
+ * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
282
+ * the client soft-strips it from the patch and emits a `console.warn`
283
+ * the rest of the update still applies, and the stored avatar is left
284
+ * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` —
285
+ * JSON-serialized updates exceed 3 KB.
285
286
  *
286
287
  * @param {string} uuid - Task UUID.
287
288
  * @param {Object} updates - Fields to update.
288
289
  * @returns {Promise<Object>}
289
290
  */
290
291
  async updateMessage(uuid, updates) {
291
- this._validateAvatarUrl(updates && updates.avatarUrl);
292
+ // Match server-side semantics: a stripped patch shouldn't overwrite the
293
+ // stored avatar with `null`. When sanitize fires, remove the field
294
+ // entirely so the existing image is preserved.
295
+ if (this._sanitizeAvatarUrl(updates)) {
296
+ delete updates.avatarUrl;
297
+ }
292
298
  const json = JSON.stringify(updates);
293
299
  this._assertPayloadSize(json, 'updateMessage');
294
300
  const encrypted = await this._encrypt(json);
@@ -381,33 +387,36 @@ class ReiClient {
381
387
  // ─── Local preflight (no network) ────────────────────────────────
382
388
 
383
389
  /**
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).
390
+ * Sanitize `avatarUrl` on an outgoing payload. If the value is unusable
391
+ * (`data:` URI / oversized / non-string), set the field to `null` on the
392
+ * payload, log a `console.warn`, and let the rest of the request go
393
+ * through. Avatar is cosmetic failing the entire schedule / instant
394
+ * call over a bad image URL is too punishing. Mirrors the server-side
395
+ * soft-strip in `@rei-standard/amsg-server` 2.3.3+ and `@rei-standard/amsg-instant`
396
+ * 0.7.1+. See standards §6.2.
389
397
  *
390
398
  * @private
391
- * @param {unknown} value
399
+ * @param {object|null|undefined} target - Payload-like object holding `avatarUrl`.
400
+ * @returns {boolean} `true` if the field was stripped, `false` otherwise.
392
401
  */
393
- _validateAvatarUrl(value) {
394
- if (value === undefined || value === null) return;
402
+ _sanitizeAvatarUrl(target) {
403
+ if (!target || typeof target !== 'object') return false;
404
+ const value = target.avatarUrl;
405
+ if (value === undefined || value === null) return false;
406
+ let reason = null;
395
407
  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
- );
408
+ reason = 'avatarUrl 必须是字符串';
409
+ } else if (/^data:/i.test(value)) {
410
+ reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
411
+ } else if (value.length > AVATAR_URL_MAX_LENGTH) {
412
+ reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
403
413
  }
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
- );
414
+ if (reason) {
415
+ console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
416
+ target.avatarUrl = null;
417
+ return true;
410
418
  }
419
+ return false;
411
420
  }
412
421
 
413
422
  /**
package/dist/index.d.ts CHANGED
@@ -181,16 +181,16 @@ class ReiClient {
181
181
  *
182
182
  * The payload is automatically encrypted before transmission.
183
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.
184
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
185
+ * client soft-strips it on the payload and emits a `console.warn` the
186
+ * schedule still ships, just without an avatar. The only throw left is
187
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
188
188
  *
189
189
  * @param {Object} payload - Schedule message payload.
190
190
  * @returns {Promise<Object>} API response body.
191
191
  */
192
192
  async scheduleMessage(payload) {
193
- this._validateAvatarUrl(payload && payload.avatarUrl);
193
+ this._sanitizeAvatarUrl(payload);
194
194
  const json = JSON.stringify(payload);
195
195
  this._assertPayloadSize(json, 'scheduleMessage');
196
196
  const encrypted = await this._encrypt(json);
@@ -230,10 +230,10 @@ class ReiClient {
230
230
  *
231
231
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
232
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.
233
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
234
+ * client soft-strips it on the payload and emits a `console.warn` the
235
+ * push still ships, just without an icon. The only throw left is
236
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
237
237
  *
238
238
  * @param {Object} payload - Instant message payload.
239
239
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -241,7 +241,7 @@ class ReiClient {
241
241
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
242
242
  */
243
243
  async sendInstant(payload, endpointPath = '/instant', opts = {}) {
244
- this._validateAvatarUrl(payload && payload.avatarUrl);
244
+ this._sanitizeAvatarUrl(payload);
245
245
  const json = JSON.stringify(payload);
246
246
  this._assertPayloadSize(json, 'sendInstant');
247
247
 
@@ -278,17 +278,23 @@ class ReiClient {
278
278
  /**
279
279
  * Update an existing scheduled message.
280
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.
281
+ * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
282
+ * the client soft-strips it from the patch and emits a `console.warn`
283
+ * the rest of the update still applies, and the stored avatar is left
284
+ * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` —
285
+ * JSON-serialized updates exceed 3 KB.
285
286
  *
286
287
  * @param {string} uuid - Task UUID.
287
288
  * @param {Object} updates - Fields to update.
288
289
  * @returns {Promise<Object>}
289
290
  */
290
291
  async updateMessage(uuid, updates) {
291
- this._validateAvatarUrl(updates && updates.avatarUrl);
292
+ // Match server-side semantics: a stripped patch shouldn't overwrite the
293
+ // stored avatar with `null`. When sanitize fires, remove the field
294
+ // entirely so the existing image is preserved.
295
+ if (this._sanitizeAvatarUrl(updates)) {
296
+ delete updates.avatarUrl;
297
+ }
292
298
  const json = JSON.stringify(updates);
293
299
  this._assertPayloadSize(json, 'updateMessage');
294
300
  const encrypted = await this._encrypt(json);
@@ -381,33 +387,36 @@ class ReiClient {
381
387
  // ─── Local preflight (no network) ────────────────────────────────
382
388
 
383
389
  /**
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).
390
+ * Sanitize `avatarUrl` on an outgoing payload. If the value is unusable
391
+ * (`data:` URI / oversized / non-string), set the field to `null` on the
392
+ * payload, log a `console.warn`, and let the rest of the request go
393
+ * through. Avatar is cosmetic failing the entire schedule / instant
394
+ * call over a bad image URL is too punishing. Mirrors the server-side
395
+ * soft-strip in `@rei-standard/amsg-server` 2.3.3+ and `@rei-standard/amsg-instant`
396
+ * 0.7.1+. See standards §6.2.
389
397
  *
390
398
  * @private
391
- * @param {unknown} value
399
+ * @param {object|null|undefined} target - Payload-like object holding `avatarUrl`.
400
+ * @returns {boolean} `true` if the field was stripped, `false` otherwise.
392
401
  */
393
- _validateAvatarUrl(value) {
394
- if (value === undefined || value === null) return;
402
+ _sanitizeAvatarUrl(target) {
403
+ if (!target || typeof target !== 'object') return false;
404
+ const value = target.avatarUrl;
405
+ if (value === undefined || value === null) return false;
406
+ let reason = null;
395
407
  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
- );
408
+ reason = 'avatarUrl 必须是字符串';
409
+ } else if (/^data:/i.test(value)) {
410
+ reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
411
+ } else if (value.length > AVATAR_URL_MAX_LENGTH) {
412
+ reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
403
413
  }
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
- );
414
+ if (reason) {
415
+ console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
416
+ target.avatarUrl = null;
417
+ return true;
410
418
  }
419
+ return false;
411
420
  }
412
421
 
413
422
  /**
package/dist/index.mjs CHANGED
@@ -95,16 +95,16 @@ var ReiClient = class {
95
95
  *
96
96
  * The payload is automatically encrypted before transmission.
97
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.
98
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
99
+ * client soft-strips it on the payload and emits a `console.warn` the
100
+ * schedule still ships, just without an avatar. The only throw left is
101
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
102
102
  *
103
103
  * @param {Object} payload - Schedule message payload.
104
104
  * @returns {Promise<Object>} API response body.
105
105
  */
106
106
  async scheduleMessage(payload) {
107
- this._validateAvatarUrl(payload && payload.avatarUrl);
107
+ this._sanitizeAvatarUrl(payload);
108
108
  const json = JSON.stringify(payload);
109
109
  this._assertPayloadSize(json, "scheduleMessage");
110
110
  const encrypted = await this._encrypt(json);
@@ -141,10 +141,10 @@ var ReiClient = class {
141
141
  *
142
142
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
143
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.
144
+ * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
145
+ * client soft-strips it on the payload and emits a `console.warn` the
146
+ * push still ships, just without an icon. The only throw left is
147
+ * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB.
148
148
  *
149
149
  * @param {Object} payload - Instant message payload.
150
150
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -152,7 +152,7 @@ var ReiClient = class {
152
152
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
153
153
  */
154
154
  async sendInstant(payload, endpointPath = "/instant", opts = {}) {
155
- this._validateAvatarUrl(payload && payload.avatarUrl);
155
+ this._sanitizeAvatarUrl(payload);
156
156
  const json = JSON.stringify(payload);
157
157
  this._assertPayloadSize(json, "sendInstant");
158
158
  const headers = { "Content-Type": "application/json" };
@@ -183,17 +183,20 @@ var ReiClient = class {
183
183
  /**
184
184
  * Update an existing scheduled message.
185
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.
186
+ * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
187
+ * the client soft-strips it from the patch and emits a `console.warn`
188
+ * the rest of the update still applies, and the stored avatar is left
189
+ * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` —
190
+ * JSON-serialized updates exceed 3 KB.
190
191
  *
191
192
  * @param {string} uuid - Task UUID.
192
193
  * @param {Object} updates - Fields to update.
193
194
  * @returns {Promise<Object>}
194
195
  */
195
196
  async updateMessage(uuid, updates) {
196
- this._validateAvatarUrl(updates && updates.avatarUrl);
197
+ if (this._sanitizeAvatarUrl(updates)) {
198
+ delete updates.avatarUrl;
199
+ }
197
200
  const json = JSON.stringify(updates);
198
201
  this._assertPayloadSize(json, "updateMessage");
199
202
  const encrypted = await this._encrypt(json);
@@ -273,33 +276,36 @@ var ReiClient = class {
273
276
  }
274
277
  // ─── Local preflight (no network) ────────────────────────────────
275
278
  /**
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).
279
+ * Sanitize `avatarUrl` on an outgoing payload. If the value is unusable
280
+ * (`data:` URI / oversized / non-string), set the field to `null` on the
281
+ * payload, log a `console.warn`, and let the rest of the request go
282
+ * through. Avatar is cosmetic failing the entire schedule / instant
283
+ * call over a bad image URL is too punishing. Mirrors the server-side
284
+ * soft-strip in `@rei-standard/amsg-server` 2.3.3+ and `@rei-standard/amsg-instant`
285
+ * 0.7.1+. See standards §6.2.
281
286
  *
282
287
  * @private
283
- * @param {unknown} value
288
+ * @param {object|null|undefined} target - Payload-like object holding `avatarUrl`.
289
+ * @returns {boolean} `true` if the field was stripped, `false` otherwise.
284
290
  */
285
- _validateAvatarUrl(value) {
286
- if (value === void 0 || value === null) return;
291
+ _sanitizeAvatarUrl(target) {
292
+ if (!target || typeof target !== "object") return false;
293
+ const value = target.avatarUrl;
294
+ if (value === void 0 || value === null) return false;
295
+ let reason = null;
287
296
  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
- );
297
+ reason = "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
298
+ } else if (/^data:/i.test(value)) {
299
+ reason = "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
300
+ } else if (value.length > AVATAR_URL_MAX_LENGTH) {
301
+ reason = `\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`;
295
302
  }
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
- );
303
+ if (reason) {
304
+ console.warn("[rei-standard-amsg-client] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", reason);
305
+ target.avatarUrl = null;
306
+ return true;
302
307
  }
308
+ return false;
303
309
  }
304
310
  /**
305
311
  * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-client",
3
- "version": "2.3.0-next.0",
3
+ "version": "2.3.0-next.2",
4
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",
@@ -33,7 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "dependencies": {
36
- "@rei-standard/amsg-shared": "0.1.0-next.0"
36
+ "@rei-standard/amsg-shared": "0.1.0-next.4"
37
37
  },
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",