@rei-standard/amsg-client 2.2.3 → 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
@@ -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
 
@@ -144,24 +180,24 @@ await client.sendInstant({
144
180
  - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
145
181
  - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
146
182
 
147
- ### 本地预校验:`avatarUrl` 与 payload 体积(2.2.3+)
183
+ ### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0-next.1+)
148
184
 
149
- `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项预检,避免一次远端往返才拿到 `413` 或 Web Push 4KB 上限报错:
185
+ `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护:
150
186
 
151
- | 触发条件 | 抛出 `Error.code` | 触发原因(背景说明,不在 message 里) |
187
+ | 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) |
152
188
  | --- | --- | --- |
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 }` 方便定位。 |
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 捕获:
157
195
 
158
196
  ```js
159
197
  try {
160
198
  await client.sendInstant(payload);
161
199
  } 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') {
200
+ if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
165
201
  // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 }
166
202
  } else {
167
203
  throw err;
@@ -169,7 +205,7 @@ try {
169
205
  }
170
206
  ```
171
207
 
172
- 服务端(`@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 这一道主要省一次远端往返。
173
209
 
174
210
  ## 导出 API(Exports)
175
211
 
package/dist/index.cjs CHANGED
@@ -19,9 +19,21 @@ 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");
25
37
  var AVATAR_URL_MAX_LENGTH = 2048;
26
38
  var PAYLOAD_LOCAL_MAX_BYTES = 3072;
27
39
  function makeLocalError(code, message, details) {
@@ -105,16 +117,16 @@ var ReiClient = class {
105
117
  *
106
118
  * The payload is automatically encrypted before transmission.
107
119
  *
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.
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.
112
124
  *
113
125
  * @param {Object} payload - Schedule message payload.
114
126
  * @returns {Promise<Object>} API response body.
115
127
  */
116
128
  async scheduleMessage(payload) {
117
- this._validateAvatarUrl(payload && payload.avatarUrl);
129
+ this._sanitizeAvatarUrl(payload);
118
130
  const json = JSON.stringify(payload);
119
131
  this._assertPayloadSize(json, "scheduleMessage");
120
132
  const encrypted = await this._encrypt(json);
@@ -151,10 +163,10 @@ var ReiClient = class {
151
163
  *
152
164
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
153
165
  *
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.
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.
158
170
  *
159
171
  * @param {Object} payload - Instant message payload.
160
172
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -162,7 +174,7 @@ var ReiClient = class {
162
174
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
163
175
  */
164
176
  async sendInstant(payload, endpointPath = "/instant", opts = {}) {
165
- this._validateAvatarUrl(payload && payload.avatarUrl);
177
+ this._sanitizeAvatarUrl(payload);
166
178
  const json = JSON.stringify(payload);
167
179
  this._assertPayloadSize(json, "sendInstant");
168
180
  const headers = { "Content-Type": "application/json" };
@@ -193,17 +205,20 @@ var ReiClient = class {
193
205
  /**
194
206
  * Update an existing scheduled message.
195
207
  *
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.
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.
200
213
  *
201
214
  * @param {string} uuid - Task UUID.
202
215
  * @param {Object} updates - Fields to update.
203
216
  * @returns {Promise<Object>}
204
217
  */
205
218
  async updateMessage(uuid, updates) {
206
- this._validateAvatarUrl(updates && updates.avatarUrl);
219
+ if (this._sanitizeAvatarUrl(updates)) {
220
+ delete updates.avatarUrl;
221
+ }
207
222
  const json = JSON.stringify(updates);
208
223
  this._assertPayloadSize(json, "updateMessage");
209
224
  const encrypted = await this._encrypt(json);
@@ -283,33 +298,36 @@ var ReiClient = class {
283
298
  }
284
299
  // ─── Local preflight (no network) ────────────────────────────────
285
300
  /**
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).
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.
291
308
  *
292
309
  * @private
293
- * @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.
294
312
  */
295
- _validateAvatarUrl(value) {
296
- 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;
297
318
  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
- );
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`;
305
324
  }
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
- );
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;
312
329
  }
330
+ return false;
313
331
  }
314
332
  /**
315
333
  * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
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).
@@ -170,16 +181,16 @@ class ReiClient {
170
181
  *
171
182
  * The payload is automatically encrypted before transmission.
172
183
  *
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.
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.
177
188
  *
178
189
  * @param {Object} payload - Schedule message payload.
179
190
  * @returns {Promise<Object>} API response body.
180
191
  */
181
192
  async scheduleMessage(payload) {
182
- this._validateAvatarUrl(payload && payload.avatarUrl);
193
+ this._sanitizeAvatarUrl(payload);
183
194
  const json = JSON.stringify(payload);
184
195
  this._assertPayloadSize(json, 'scheduleMessage');
185
196
  const encrypted = await this._encrypt(json);
@@ -219,10 +230,10 @@ class ReiClient {
219
230
  *
220
231
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
221
232
  *
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.
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.
226
237
  *
227
238
  * @param {Object} payload - Instant message payload.
228
239
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -230,7 +241,7 @@ class ReiClient {
230
241
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
231
242
  */
232
243
  async sendInstant(payload, endpointPath = '/instant', opts = {}) {
233
- this._validateAvatarUrl(payload && payload.avatarUrl);
244
+ this._sanitizeAvatarUrl(payload);
234
245
  const json = JSON.stringify(payload);
235
246
  this._assertPayloadSize(json, 'sendInstant');
236
247
 
@@ -267,17 +278,23 @@ class ReiClient {
267
278
  /**
268
279
  * Update an existing scheduled message.
269
280
  *
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.
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.
274
286
  *
275
287
  * @param {string} uuid - Task UUID.
276
288
  * @param {Object} updates - Fields to update.
277
289
  * @returns {Promise<Object>}
278
290
  */
279
291
  async updateMessage(uuid, updates) {
280
- 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
+ }
281
298
  const json = JSON.stringify(updates);
282
299
  this._assertPayloadSize(json, 'updateMessage');
283
300
  const encrypted = await this._encrypt(json);
@@ -370,33 +387,36 @@ class ReiClient {
370
387
  // ─── Local preflight (no network) ────────────────────────────────
371
388
 
372
389
  /**
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).
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.
378
397
  *
379
398
  * @private
380
- * @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.
381
401
  */
382
- _validateAvatarUrl(value) {
383
- 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;
384
407
  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
- );
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`;
392
413
  }
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
- );
414
+ if (reason) {
415
+ console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
416
+ target.avatarUrl = null;
417
+ return true;
399
418
  }
419
+ return false;
400
420
  }
401
421
 
402
422
  /**
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).
@@ -170,16 +181,16 @@ class ReiClient {
170
181
  *
171
182
  * The payload is automatically encrypted before transmission.
172
183
  *
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.
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.
177
188
  *
178
189
  * @param {Object} payload - Schedule message payload.
179
190
  * @returns {Promise<Object>} API response body.
180
191
  */
181
192
  async scheduleMessage(payload) {
182
- this._validateAvatarUrl(payload && payload.avatarUrl);
193
+ this._sanitizeAvatarUrl(payload);
183
194
  const json = JSON.stringify(payload);
184
195
  this._assertPayloadSize(json, 'scheduleMessage');
185
196
  const encrypted = await this._encrypt(json);
@@ -219,10 +230,10 @@ class ReiClient {
219
230
  *
220
231
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
221
232
  *
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.
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.
226
237
  *
227
238
  * @param {Object} payload - Instant message payload.
228
239
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -230,7 +241,7 @@ class ReiClient {
230
241
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
231
242
  */
232
243
  async sendInstant(payload, endpointPath = '/instant', opts = {}) {
233
- this._validateAvatarUrl(payload && payload.avatarUrl);
244
+ this._sanitizeAvatarUrl(payload);
234
245
  const json = JSON.stringify(payload);
235
246
  this._assertPayloadSize(json, 'sendInstant');
236
247
 
@@ -267,17 +278,23 @@ class ReiClient {
267
278
  /**
268
279
  * Update an existing scheduled message.
269
280
  *
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.
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.
274
286
  *
275
287
  * @param {string} uuid - Task UUID.
276
288
  * @param {Object} updates - Fields to update.
277
289
  * @returns {Promise<Object>}
278
290
  */
279
291
  async updateMessage(uuid, updates) {
280
- 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
+ }
281
298
  const json = JSON.stringify(updates);
282
299
  this._assertPayloadSize(json, 'updateMessage');
283
300
  const encrypted = await this._encrypt(json);
@@ -370,33 +387,36 @@ class ReiClient {
370
387
  // ─── Local preflight (no network) ────────────────────────────────
371
388
 
372
389
  /**
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).
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.
378
397
  *
379
398
  * @private
380
- * @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.
381
401
  */
382
- _validateAvatarUrl(value) {
383
- 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;
384
407
  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
- );
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`;
392
413
  }
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
- );
414
+ if (reason) {
415
+ console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
416
+ target.avatarUrl = null;
417
+ return true;
399
418
  }
419
+ return false;
400
420
  }
401
421
 
402
422
  /**
package/dist/index.mjs CHANGED
@@ -1,4 +1,17 @@
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";
2
15
  var AVATAR_URL_MAX_LENGTH = 2048;
3
16
  var PAYLOAD_LOCAL_MAX_BYTES = 3072;
4
17
  function makeLocalError(code, message, details) {
@@ -82,16 +95,16 @@ var ReiClient = class {
82
95
  *
83
96
  * The payload is automatically encrypted before transmission.
84
97
  *
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.
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.
89
102
  *
90
103
  * @param {Object} payload - Schedule message payload.
91
104
  * @returns {Promise<Object>} API response body.
92
105
  */
93
106
  async scheduleMessage(payload) {
94
- this._validateAvatarUrl(payload && payload.avatarUrl);
107
+ this._sanitizeAvatarUrl(payload);
95
108
  const json = JSON.stringify(payload);
96
109
  this._assertPayloadSize(json, "scheduleMessage");
97
110
  const encrypted = await this._encrypt(json);
@@ -128,10 +141,10 @@ var ReiClient = class {
128
141
  *
129
142
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
130
143
  *
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.
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.
135
148
  *
136
149
  * @param {Object} payload - Instant message payload.
137
150
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -139,7 +152,7 @@ var ReiClient = class {
139
152
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
140
153
  */
141
154
  async sendInstant(payload, endpointPath = "/instant", opts = {}) {
142
- this._validateAvatarUrl(payload && payload.avatarUrl);
155
+ this._sanitizeAvatarUrl(payload);
143
156
  const json = JSON.stringify(payload);
144
157
  this._assertPayloadSize(json, "sendInstant");
145
158
  const headers = { "Content-Type": "application/json" };
@@ -170,17 +183,20 @@ var ReiClient = class {
170
183
  /**
171
184
  * Update an existing scheduled message.
172
185
  *
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.
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.
177
191
  *
178
192
  * @param {string} uuid - Task UUID.
179
193
  * @param {Object} updates - Fields to update.
180
194
  * @returns {Promise<Object>}
181
195
  */
182
196
  async updateMessage(uuid, updates) {
183
- this._validateAvatarUrl(updates && updates.avatarUrl);
197
+ if (this._sanitizeAvatarUrl(updates)) {
198
+ delete updates.avatarUrl;
199
+ }
184
200
  const json = JSON.stringify(updates);
185
201
  this._assertPayloadSize(json, "updateMessage");
186
202
  const encrypted = await this._encrypt(json);
@@ -260,33 +276,36 @@ var ReiClient = class {
260
276
  }
261
277
  // ─── Local preflight (no network) ────────────────────────────────
262
278
  /**
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).
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.
268
286
  *
269
287
  * @private
270
- * @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.
271
290
  */
272
- _validateAvatarUrl(value) {
273
- 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;
274
296
  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
- );
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`;
282
302
  }
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
- );
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;
289
307
  }
308
+ return false;
290
309
  }
291
310
  /**
292
311
  * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
@@ -382,5 +401,16 @@ var ReiClient = class {
382
401
  }
383
402
  };
384
403
  export {
385
- ReiClient
404
+ MESSAGE_KIND,
405
+ MESSAGE_TYPE,
406
+ PUSH_SOURCE,
407
+ ReiClient,
408
+ buildContentPush,
409
+ buildErrorPush,
410
+ buildReasoningPush,
411
+ buildToolRequestPush,
412
+ isContentPush,
413
+ isErrorPush,
414
+ isReasoningPush,
415
+ isToolRequestPush
386
416
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-client",
3
- "version": "2.2.3",
4
- "description": "ReiStandard Active Messaging browser client SDK",
3
+ "version": "2.3.0-next.2",
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.4"
37
+ },
34
38
  "devDependencies": {
35
39
  "tsup": "^8.0.0",
36
40
  "typescript": "^5.0.0"