@rei-standard/amsg-client 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。
4
4
 
5
+ ## v2.4.0 — SSE consumer
6
+
7
+ 新增 `consumeInstantStream(payload, endpointPath?, options)`:消费 amsg-instant 0.9.0+ 的 SSE 默认响应,按 frame 解析 `event: payload` / `event: done` / `event: error` 分发到 `options.onPayload`。前台场景下 push 不再绕 push service → SW → IDB → main thread 整条链路,延迟少一个数量级。详见下方 [SSE 流消费](#sse-流消费-consumeinstantstream240配合-amsg-instant-090)。请求体默认不再由 client 做本地体积限制;需要本地护栏时可在构造器传 `maxPayloadBytes`。
8
+
5
9
  ## v2.3.0 — Shared push types
6
10
 
7
11
  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 用的便利出口。
@@ -175,30 +179,77 @@ await client.sendInstant({
175
179
 
176
180
  `splitPattern` 类型是 `string | string[]`。`scheduleMessage` 也支持,`updateMessage` 可显式传 `splitPattern: null` 重置回默认。client SDK 完全透传不校验,所有错误在 Worker / Server 端返回(每项 ≤ 200 字符、数组 ≤ 10 项、必须能 `new RegExp()` 通过)。
177
181
 
178
- **两个常见 footgun**:
182
+ **两个常见坑**:
179
183
 
180
184
  - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
181
185
  - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
182
186
 
183
- ### 本地软清空:`avatarUrl` payload 体积(2.2.4+ / 2.3.0+)
187
+ ### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+)
188
+
189
+ `sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,前台延迟从约 1–3s(push service → SW → IDB → window)降到次百毫秒。Web Push backup 同时**常开 always-on**(即使 SSE enqueue 成功也照样发一份),用 SW / client 端按 `messageId` 做 dedupe 把两路收敛回一次。前台场景应该改用 `consumeInstantStream()`。
190
+
191
+ ```js
192
+ const abort = new AbortController();
193
+
194
+ try {
195
+ await client.consumeInstantStream(payload, '/instant', {
196
+ onPayload: async (push) => {
197
+ // 跟 SW 收到的 wire format 字节级一致:含 messageKind / sessionId / messageId
198
+ // 等。按 messageKind 分轨写 IDB / 渲染 / 更新 UI 状态机即可。
199
+ await routePushToIDB(push);
200
+ },
201
+ onError: (err) => log.warn('stream error', err), // 通知性,不抑制 throw
202
+ onDone: () => stopSpinner(),
203
+ signal: abort.signal,
204
+ });
205
+ } catch (err) {
206
+ // 网络 / 协议 / abort / onPayload 抛错都会到这里
207
+ showError(err);
208
+ }
209
+ ```
210
+
211
+ 请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`。两条投递路径同时跑:
212
+
213
+ 1. **SSE 直送**(首选)——payload 走 `event: payload` 直接到 `onPayload`。
214
+ 2. **Web Push always-on backup**——成功 enqueue 的 payload 也会通过 `pushSubscription` 发一份;SSE 写失败 / 客户端断开 / enqueue throw 时也走这条路兜底。
184
215
 
185
- `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护:
216
+ 同一 `messageId` 两路都到,由 SW dedupe gate 或客户端按 ID 幂等去重收敛成一次业务投递与一次(必要时的)通知。
217
+
218
+ #### 错误语义
219
+
220
+ 任何失败——`fetch` 网络异常、非 2xx 响应、非 `text/event-stream` `Content-Type`、SSE `event: error` 帧、`onPayload` 回调抛错、`signal` abort——都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不要把它当 try/catch 替代。
221
+
222
+ #### 端点 / transport 配置
223
+
224
+ `endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。
225
+
226
+ ### 本地软清空:`avatarUrl` 与可选 payload 体积上限(2.2.4+ / 2.4.0+)
227
+
228
+ `scheduleMessage` / `sendInstant` / `consumeInstantStream` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;如果你希望在 SDK 本地先挡住过大的请求,可以在构造器显式传 `maxPayloadBytes`:
229
+
230
+ ```js
231
+ const client = new ReiClient({
232
+ baseUrl: '/api/v1',
233
+ userId,
234
+ maxPayloadBytes: 256_000, // 可选;默认 null / 不限制
235
+ });
236
+ ```
186
237
 
187
238
  | 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) |
188
239
  | --- | --- | --- |
189
240
  | `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`,请求照发(`updateMessage` 从 patch 里删除该字段,保留服务端原头像) | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 |
190
241
  | `payload.avatarUrl` 长度 > 2048 字符 | 同上 | 同上。建议用 CDN 缩略图 URL。 |
191
242
  | `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 |
192
- | `JSON.stringify(payload)` UTF-8 字节数 > 3072 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 远端网关 / Web Push 4KB 硬上限的本地兜底。 |
243
+ | 已配置 `maxPayloadBytes`,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 只在调用方主动需要本地请求体护栏时启用。Web Push 单条回复超限由 `amsg-instant` 的 BlobStore / multipart 输出链路处理,不靠 client 限制请求体。 |
193
244
 
194
- 头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。`PAYLOAD_TOO_LARGE_LOCAL` 仍然是真正的"整包过大"信号,照常用 try/catch 捕获:
245
+ 头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。未配置 `maxPayloadBytes` 时不会产生 `PAYLOAD_TOO_LARGE_LOCAL`;配置后照常用 try/catch 捕获:
195
246
 
196
247
  ```js
197
248
  try {
198
249
  await client.sendInstant(payload);
199
250
  } catch (err) {
200
251
  if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
201
- // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 }
252
+ // err.details = { method: 'sendInstant', actualBytes: 87320, limitBytes: 256000 }
202
253
  } else {
203
254
  throw err;
204
255
  }
package/dist/index.cjs CHANGED
@@ -35,7 +35,6 @@ __export(src_exports, {
35
35
  module.exports = __toCommonJS(src_exports);
36
36
  var import_amsg_shared = require("@rei-standard/amsg-shared");
37
37
  var AVATAR_URL_MAX_LENGTH = 2048;
38
- var PAYLOAD_LOCAL_MAX_BYTES = 3072;
39
38
  function makeLocalError(code, message, details) {
40
39
  const err = new Error(`[rei-standard-amsg-client] ${message}`);
41
40
  err.code = code;
@@ -67,6 +66,7 @@ var ReiClient = class {
67
66
  this._userKey = null;
68
67
  this._instantEncryption = instantEncryption;
69
68
  this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
69
+ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
70
70
  }
71
71
  /**
72
72
  * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
@@ -119,8 +119,8 @@ var ReiClient = class {
119
119
  *
120
120
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
121
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.
122
+ * schedule still ships, just without an avatar. If `maxPayloadBytes` is
123
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
124
124
  *
125
125
  * @param {Object} payload - Schedule message payload.
126
126
  * @returns {Promise<Object>} API response body.
@@ -165,8 +165,8 @@ var ReiClient = class {
165
165
  *
166
166
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
167
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.
168
+ * push still ships, just without an icon. If `maxPayloadBytes` is
169
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
170
170
  *
171
171
  * @param {Object} payload - Instant message payload.
172
172
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -202,14 +202,153 @@ var ReiClient = class {
202
202
  });
203
203
  return res.json();
204
204
  }
205
+ /**
206
+ * Consume an instant SSE stream.
207
+ *
208
+ * Error semantics: any failure (network, protocol, abort, `onPayload`
209
+ * callback throwing) rejects the returned Promise. `options.onError`,
210
+ * when provided, is a side-channel notification (e.g. for logging or
211
+ * UI flashes) and fires before the rejection — it does not suppress
212
+ * it. Always wrap calls in `try / await` and treat the rejection as
213
+ * the canonical error path.
214
+ *
215
+ * @param {Object} payload - Instant message payload.
216
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
217
+ * @param {Object} options
218
+ * @param {Record<string, string>} [options.headers]
219
+ * @param {(payload: unknown) => Promise<void> | void} options.onPayload
220
+ * @param {(error: unknown) => void} [options.onError]
221
+ * @param {() => void} [options.onDone]
222
+ * @param {AbortSignal} [options.signal]
223
+ * @returns {Promise<void>}
224
+ */
225
+ async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
226
+ this._sanitizeAvatarUrl(payload);
227
+ const json = JSON.stringify(payload);
228
+ this._assertPayloadSize(json, "consumeInstantStream");
229
+ const headers = { "Content-Type": "application/json", ...options.headers || {} };
230
+ let body;
231
+ if (this._instantEncryption === false) {
232
+ body = json;
233
+ if (this._instantClientToken) {
234
+ headers["X-Client-Token"] = this._instantClientToken;
235
+ }
236
+ } else {
237
+ const encrypted = await this._encrypt(json);
238
+ headers["X-User-Id"] = this._userId;
239
+ headers["X-Payload-Encrypted"] = "true";
240
+ headers["X-Encryption-Version"] = "1";
241
+ body = JSON.stringify(encrypted);
242
+ }
243
+ const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
244
+ const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
245
+ method: "POST",
246
+ headers,
247
+ body,
248
+ signal: options.signal
249
+ });
250
+ if (!res.ok) {
251
+ const text = await res.text().catch(() => "");
252
+ throw new Error(`Instant request failed: ${res.status} ${text}`);
253
+ }
254
+ const contentType = res.headers.get("content-type") || "";
255
+ if (!contentType.includes("text/event-stream")) {
256
+ const text = await res.text().catch(() => "");
257
+ throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
258
+ }
259
+ if (!res.body) {
260
+ throw new Error("Response body is null");
261
+ }
262
+ const reader = res.body.getReader();
263
+ const decoder = new TextDecoder();
264
+ let buffer = "";
265
+ let thrown;
266
+ try {
267
+ while (true) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ buffer += decoder.decode(value, { stream: true });
271
+ const parts = buffer.split("\n\n");
272
+ buffer = parts.pop() || "";
273
+ for (const part of parts) {
274
+ if (!part.trim()) continue;
275
+ let eventName = "message";
276
+ let data = "";
277
+ const lines = part.split("\n");
278
+ for (const line of lines) {
279
+ if (line.startsWith(":")) continue;
280
+ if (line.startsWith("event:")) {
281
+ eventName = line.slice(6).trim();
282
+ } else if (line.startsWith("data:")) {
283
+ const piece = line.slice(5).trim();
284
+ data = data ? `${data}
285
+ ${piece}` : piece;
286
+ }
287
+ }
288
+ if (eventName === "done") {
289
+ if (options.onDone) options.onDone();
290
+ return;
291
+ }
292
+ if (eventName === "error") {
293
+ let parsedErr;
294
+ try {
295
+ parsedErr = JSON.parse(data);
296
+ } catch {
297
+ parsedErr = { code: "PARSE_ERROR", message: data };
298
+ }
299
+ const err = new Error(parsedErr.message || "Stream error");
300
+ err.code = parsedErr.code;
301
+ thrown = err;
302
+ return;
303
+ }
304
+ if (eventName === "payload") {
305
+ let parsedPayload;
306
+ try {
307
+ parsedPayload = JSON.parse(data);
308
+ } catch {
309
+ continue;
310
+ }
311
+ if (options.onPayload) {
312
+ await options.onPayload(parsedPayload);
313
+ }
314
+ }
315
+ }
316
+ }
317
+ if (options.onDone) options.onDone();
318
+ } catch (err) {
319
+ thrown = err;
320
+ } finally {
321
+ if (thrown) {
322
+ try {
323
+ await reader.cancel(thrown);
324
+ } catch {
325
+ }
326
+ if (options.onError) {
327
+ try {
328
+ options.onError(thrown);
329
+ } catch {
330
+ }
331
+ }
332
+ try {
333
+ reader.releaseLock();
334
+ } catch {
335
+ }
336
+ throw thrown;
337
+ }
338
+ try {
339
+ reader.releaseLock();
340
+ } catch {
341
+ }
342
+ }
343
+ }
205
344
  /**
206
345
  * Update an existing scheduled message.
207
346
  *
208
347
  * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
209
348
  * the client soft-strips it from the patch and emits a `console.warn` —
210
349
  * 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.
350
+ * untouched. If `maxPayloadBytes` is configured, oversized JSON patches
351
+ * throw `PAYLOAD_TOO_LARGE_LOCAL`.
213
352
  *
214
353
  * @param {string} uuid - Task UUID.
215
354
  * @param {Object} updates - Fields to update.
@@ -330,21 +469,22 @@ var ReiClient = class {
330
469
  return false;
331
470
  }
332
471
  /**
333
- * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
334
- * remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
335
- * caller a precise local error pointing at the size cap.
472
+ * Enforce the optional local request payload cap before encryption.
473
+ * By default there is no SDK-level request-size limit; runtime, proxy,
474
+ * database, and LLM-provider limits remain the deployer's boundary.
336
475
  *
337
476
  * @private
338
477
  * @param {string} bodyJson - `JSON.stringify(payload)`.
339
478
  * @param {string} methodName
340
479
  */
341
480
  _assertPayloadSize(bodyJson, methodName) {
481
+ if (this._maxPayloadBytes == null) return;
342
482
  const bytes = new TextEncoder().encode(bodyJson).length;
343
- if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
483
+ if (bytes > this._maxPayloadBytes) {
344
484
  throw makeLocalError(
345
485
  "PAYLOAD_TOO_LARGE_LOCAL",
346
- `${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${PAYLOAD_LOCAL_MAX_BYTES} \u5B57\u8282`,
347
- { method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
486
+ `${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${this._maxPayloadBytes} \u5B57\u8282`,
487
+ { method: methodName, actualBytes: bytes, limitBytes: this._maxPayloadBytes }
348
488
  );
349
489
  }
350
490
  }
@@ -422,3 +562,10 @@ var ReiClient = class {
422
562
  return arr;
423
563
  }
424
564
  };
565
+ function normalizeMaxPayloadBytes(value) {
566
+ if (value === void 0 || value === null) return null;
567
+ if (!Number.isInteger(value) || value <= 0) {
568
+ throw new TypeError("[rei-standard-amsg-client] maxPayloadBytes must be a positive integer when set");
569
+ }
570
+ return value;
571
+ }
package/dist/index.d.cts CHANGED
@@ -61,6 +61,9 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
61
61
  * a *weak* shared secret — it ships inside any
62
62
  * frontend bundle that uses it, so devtools can
63
63
  * read it. Use for casual URL-direct abuse only.
64
+ * @property {number|null} [maxPayloadBytes=null] - Optional local UTF-8 byte cap for outgoing request
65
+ * payloads before encryption. `null` / omitted means
66
+ * no SDK-level request-size limit.
64
67
  */
65
68
 
66
69
  /**
@@ -71,15 +74,6 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
71
74
  */
72
75
  const AVATAR_URL_MAX_LENGTH = 2048;
73
76
 
74
- /**
75
- * Max byte length of a single outgoing payload (3 KB, measured pre-encryption
76
- * on the plaintext JSON body). Anything over this is almost certainly a base64
77
- * avatar smuggled into `avatarUrl` and will trigger downstream `413 Payload
78
- * Too Large` or hit the Web Push 4 KB hard limit at delivery. We bail locally
79
- * to save a remote round-trip and give a precise error.
80
- */
81
- const PAYLOAD_LOCAL_MAX_BYTES = 3072;
82
-
83
77
  function makeLocalError(code, message, details) {
84
78
  const err = new Error(`[rei-standard-amsg-client] ${message}`);
85
79
  err.code = code;
@@ -122,6 +116,8 @@ class ReiClient {
122
116
  this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
123
117
  ? config.instantClientToken
124
118
  : '';
119
+ /** @private */
120
+ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
125
121
  }
126
122
 
127
123
  /**
@@ -183,8 +179,8 @@ class ReiClient {
183
179
  *
184
180
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
185
181
  * 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.
182
+ * schedule still ships, just without an avatar. If `maxPayloadBytes` is
183
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
188
184
  *
189
185
  * @param {Object} payload - Schedule message payload.
190
186
  * @returns {Promise<Object>} API response body.
@@ -232,8 +228,8 @@ class ReiClient {
232
228
  *
233
229
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
234
230
  * 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.
231
+ * push still ships, just without an icon. If `maxPayloadBytes` is
232
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
237
233
  *
238
234
  * @param {Object} payload - Instant message payload.
239
235
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -275,14 +271,163 @@ class ReiClient {
275
271
  return res.json();
276
272
  }
277
273
 
274
+ /**
275
+ * Consume an instant SSE stream.
276
+ *
277
+ * Error semantics: any failure (network, protocol, abort, `onPayload`
278
+ * callback throwing) rejects the returned Promise. `options.onError`,
279
+ * when provided, is a side-channel notification (e.g. for logging or
280
+ * UI flashes) and fires before the rejection — it does not suppress
281
+ * it. Always wrap calls in `try / await` and treat the rejection as
282
+ * the canonical error path.
283
+ *
284
+ * @param {Object} payload - Instant message payload.
285
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
286
+ * @param {Object} options
287
+ * @param {Record<string, string>} [options.headers]
288
+ * @param {(payload: unknown) => Promise<void> | void} options.onPayload
289
+ * @param {(error: unknown) => void} [options.onError]
290
+ * @param {() => void} [options.onDone]
291
+ * @param {AbortSignal} [options.signal]
292
+ * @returns {Promise<void>}
293
+ */
294
+ async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
295
+ this._sanitizeAvatarUrl(payload);
296
+ const json = JSON.stringify(payload);
297
+ this._assertPayloadSize(json, 'consumeInstantStream');
298
+
299
+ const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
300
+ let body;
301
+
302
+ if (this._instantEncryption === false) {
303
+ body = json;
304
+ if (this._instantClientToken) {
305
+ headers['X-Client-Token'] = this._instantClientToken;
306
+ }
307
+ } else {
308
+ const encrypted = await this._encrypt(json);
309
+ headers['X-User-Id'] = this._userId;
310
+ headers['X-Payload-Encrypted'] = 'true';
311
+ headers['X-Encryption-Version'] = '1';
312
+ body = JSON.stringify(encrypted);
313
+ }
314
+
315
+ const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
316
+ const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
317
+ method: 'POST',
318
+ headers,
319
+ body,
320
+ signal: options.signal
321
+ });
322
+
323
+ if (!res.ok) {
324
+ const text = await res.text().catch(() => '');
325
+ throw new Error(`Instant request failed: ${res.status} ${text}`);
326
+ }
327
+
328
+ const contentType = res.headers.get('content-type') || '';
329
+ if (!contentType.includes('text/event-stream')) {
330
+ const text = await res.text().catch(() => '');
331
+ throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
332
+ }
333
+
334
+ if (!res.body) {
335
+ throw new Error('Response body is null');
336
+ }
337
+
338
+ const reader = res.body.getReader();
339
+ const decoder = new TextDecoder();
340
+ let buffer = '';
341
+ let thrown;
342
+
343
+ try {
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+
348
+ buffer += decoder.decode(value, { stream: true });
349
+ const parts = buffer.split('\n\n');
350
+ buffer = parts.pop() || ''; // last part may be incomplete
351
+
352
+ for (const part of parts) {
353
+ if (!part.trim()) continue;
354
+
355
+ let eventName = 'message';
356
+ // Per SSE spec multiple `data:` lines in one event concatenate
357
+ // with `\n`. Our own server always emits a single data line,
358
+ // but `consumeInstantStream` is a general-purpose consumer.
359
+ let data = '';
360
+
361
+ const lines = part.split('\n');
362
+ for (const line of lines) {
363
+ if (line.startsWith(':')) continue; // keepalive comment
364
+ if (line.startsWith('event:')) {
365
+ eventName = line.slice(6).trim();
366
+ } else if (line.startsWith('data:')) {
367
+ const piece = line.slice(5).trim();
368
+ data = data ? `${data}\n${piece}` : piece;
369
+ }
370
+ }
371
+
372
+ if (eventName === 'done') {
373
+ if (options.onDone) options.onDone();
374
+ return;
375
+ }
376
+
377
+ if (eventName === 'error') {
378
+ let parsedErr;
379
+ try {
380
+ parsedErr = JSON.parse(data);
381
+ } catch {
382
+ parsedErr = { code: 'PARSE_ERROR', message: data };
383
+ }
384
+ const err = new Error(parsedErr.message || 'Stream error');
385
+ err.code = parsedErr.code;
386
+ thrown = err;
387
+ return; // exit loop, finally re-throws
388
+ }
389
+
390
+ if (eventName === 'payload') {
391
+ let parsedPayload;
392
+ try {
393
+ parsedPayload = JSON.parse(data);
394
+ } catch {
395
+ continue;
396
+ }
397
+ if (options.onPayload) {
398
+ await options.onPayload(parsedPayload);
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ // Stream ended without `event: done` — treat EOF as done.
405
+ if (options.onDone) options.onDone();
406
+ } catch (err) {
407
+ thrown = err;
408
+ } finally {
409
+ // Always notify onError (side-channel) and always throw — callers
410
+ // rely on Promise rejection as the canonical failure signal.
411
+ if (thrown) {
412
+ try { await reader.cancel(thrown); } catch { /* already cancelled */ }
413
+ if (options.onError) {
414
+ try { options.onError(thrown); } catch { /* observer can't break the throw */ }
415
+ }
416
+ try { reader.releaseLock(); } catch { /* already released */ }
417
+ throw thrown;
418
+ }
419
+ try { reader.releaseLock(); } catch { /* already released */ }
420
+ }
421
+ }
422
+
278
423
  /**
279
424
  * Update an existing scheduled message.
280
425
  *
281
426
  * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
282
427
  * the client soft-strips it from the patch and emits a `console.warn` —
283
428
  * 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.
429
+ * untouched. If `maxPayloadBytes` is configured, oversized JSON patches
430
+ * throw `PAYLOAD_TOO_LARGE_LOCAL`.
286
431
  *
287
432
  * @param {string} uuid - Task UUID.
288
433
  * @param {Object} updates - Fields to update.
@@ -420,21 +565,22 @@ class ReiClient {
420
565
  }
421
566
 
422
567
  /**
423
- * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
424
- * remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
425
- * caller a precise local error pointing at the size cap.
568
+ * Enforce the optional local request payload cap before encryption.
569
+ * By default there is no SDK-level request-size limit; runtime, proxy,
570
+ * database, and LLM-provider limits remain the deployer's boundary.
426
571
  *
427
572
  * @private
428
573
  * @param {string} bodyJson - `JSON.stringify(payload)`.
429
574
  * @param {string} methodName
430
575
  */
431
576
  _assertPayloadSize(bodyJson, methodName) {
577
+ if (this._maxPayloadBytes == null) return;
432
578
  const bytes = new TextEncoder().encode(bodyJson).length;
433
- if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
579
+ if (bytes > this._maxPayloadBytes) {
434
580
  throw makeLocalError(
435
581
  'PAYLOAD_TOO_LARGE_LOCAL',
436
- `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${PAYLOAD_LOCAL_MAX_BYTES} 字节`,
437
- { method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
582
+ `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${this._maxPayloadBytes} 字节`,
583
+ { method: methodName, actualBytes: bytes, limitBytes: this._maxPayloadBytes }
438
584
  );
439
585
  }
440
586
  }
@@ -527,4 +673,12 @@ class ReiClient {
527
673
  }
528
674
  }
529
675
 
676
+ function normalizeMaxPayloadBytes(value) {
677
+ if (value === undefined || value === null) return null;
678
+ if (!Number.isInteger(value) || value <= 0) {
679
+ throw new TypeError('[rei-standard-amsg-client] maxPayloadBytes must be a positive integer when set');
680
+ }
681
+ return value;
682
+ }
683
+
530
684
  export { ReiClient };
package/dist/index.d.ts CHANGED
@@ -61,6 +61,9 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
61
61
  * a *weak* shared secret — it ships inside any
62
62
  * frontend bundle that uses it, so devtools can
63
63
  * read it. Use for casual URL-direct abuse only.
64
+ * @property {number|null} [maxPayloadBytes=null] - Optional local UTF-8 byte cap for outgoing request
65
+ * payloads before encryption. `null` / omitted means
66
+ * no SDK-level request-size limit.
64
67
  */
65
68
 
66
69
  /**
@@ -71,15 +74,6 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
71
74
  */
72
75
  const AVATAR_URL_MAX_LENGTH = 2048;
73
76
 
74
- /**
75
- * Max byte length of a single outgoing payload (3 KB, measured pre-encryption
76
- * on the plaintext JSON body). Anything over this is almost certainly a base64
77
- * avatar smuggled into `avatarUrl` and will trigger downstream `413 Payload
78
- * Too Large` or hit the Web Push 4 KB hard limit at delivery. We bail locally
79
- * to save a remote round-trip and give a precise error.
80
- */
81
- const PAYLOAD_LOCAL_MAX_BYTES = 3072;
82
-
83
77
  function makeLocalError(code, message, details) {
84
78
  const err = new Error(`[rei-standard-amsg-client] ${message}`);
85
79
  err.code = code;
@@ -122,6 +116,8 @@ class ReiClient {
122
116
  this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
123
117
  ? config.instantClientToken
124
118
  : '';
119
+ /** @private */
120
+ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
125
121
  }
126
122
 
127
123
  /**
@@ -183,8 +179,8 @@ class ReiClient {
183
179
  *
184
180
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
185
181
  * 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.
182
+ * schedule still ships, just without an avatar. If `maxPayloadBytes` is
183
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
188
184
  *
189
185
  * @param {Object} payload - Schedule message payload.
190
186
  * @returns {Promise<Object>} API response body.
@@ -232,8 +228,8 @@ class ReiClient {
232
228
  *
233
229
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
234
230
  * 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.
231
+ * push still ships, just without an icon. If `maxPayloadBytes` is
232
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
237
233
  *
238
234
  * @param {Object} payload - Instant message payload.
239
235
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -275,14 +271,163 @@ class ReiClient {
275
271
  return res.json();
276
272
  }
277
273
 
274
+ /**
275
+ * Consume an instant SSE stream.
276
+ *
277
+ * Error semantics: any failure (network, protocol, abort, `onPayload`
278
+ * callback throwing) rejects the returned Promise. `options.onError`,
279
+ * when provided, is a side-channel notification (e.g. for logging or
280
+ * UI flashes) and fires before the rejection — it does not suppress
281
+ * it. Always wrap calls in `try / await` and treat the rejection as
282
+ * the canonical error path.
283
+ *
284
+ * @param {Object} payload - Instant message payload.
285
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
286
+ * @param {Object} options
287
+ * @param {Record<string, string>} [options.headers]
288
+ * @param {(payload: unknown) => Promise<void> | void} options.onPayload
289
+ * @param {(error: unknown) => void} [options.onError]
290
+ * @param {() => void} [options.onDone]
291
+ * @param {AbortSignal} [options.signal]
292
+ * @returns {Promise<void>}
293
+ */
294
+ async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
295
+ this._sanitizeAvatarUrl(payload);
296
+ const json = JSON.stringify(payload);
297
+ this._assertPayloadSize(json, 'consumeInstantStream');
298
+
299
+ const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
300
+ let body;
301
+
302
+ if (this._instantEncryption === false) {
303
+ body = json;
304
+ if (this._instantClientToken) {
305
+ headers['X-Client-Token'] = this._instantClientToken;
306
+ }
307
+ } else {
308
+ const encrypted = await this._encrypt(json);
309
+ headers['X-User-Id'] = this._userId;
310
+ headers['X-Payload-Encrypted'] = 'true';
311
+ headers['X-Encryption-Version'] = '1';
312
+ body = JSON.stringify(encrypted);
313
+ }
314
+
315
+ const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
316
+ const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
317
+ method: 'POST',
318
+ headers,
319
+ body,
320
+ signal: options.signal
321
+ });
322
+
323
+ if (!res.ok) {
324
+ const text = await res.text().catch(() => '');
325
+ throw new Error(`Instant request failed: ${res.status} ${text}`);
326
+ }
327
+
328
+ const contentType = res.headers.get('content-type') || '';
329
+ if (!contentType.includes('text/event-stream')) {
330
+ const text = await res.text().catch(() => '');
331
+ throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
332
+ }
333
+
334
+ if (!res.body) {
335
+ throw new Error('Response body is null');
336
+ }
337
+
338
+ const reader = res.body.getReader();
339
+ const decoder = new TextDecoder();
340
+ let buffer = '';
341
+ let thrown;
342
+
343
+ try {
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+
348
+ buffer += decoder.decode(value, { stream: true });
349
+ const parts = buffer.split('\n\n');
350
+ buffer = parts.pop() || ''; // last part may be incomplete
351
+
352
+ for (const part of parts) {
353
+ if (!part.trim()) continue;
354
+
355
+ let eventName = 'message';
356
+ // Per SSE spec multiple `data:` lines in one event concatenate
357
+ // with `\n`. Our own server always emits a single data line,
358
+ // but `consumeInstantStream` is a general-purpose consumer.
359
+ let data = '';
360
+
361
+ const lines = part.split('\n');
362
+ for (const line of lines) {
363
+ if (line.startsWith(':')) continue; // keepalive comment
364
+ if (line.startsWith('event:')) {
365
+ eventName = line.slice(6).trim();
366
+ } else if (line.startsWith('data:')) {
367
+ const piece = line.slice(5).trim();
368
+ data = data ? `${data}\n${piece}` : piece;
369
+ }
370
+ }
371
+
372
+ if (eventName === 'done') {
373
+ if (options.onDone) options.onDone();
374
+ return;
375
+ }
376
+
377
+ if (eventName === 'error') {
378
+ let parsedErr;
379
+ try {
380
+ parsedErr = JSON.parse(data);
381
+ } catch {
382
+ parsedErr = { code: 'PARSE_ERROR', message: data };
383
+ }
384
+ const err = new Error(parsedErr.message || 'Stream error');
385
+ err.code = parsedErr.code;
386
+ thrown = err;
387
+ return; // exit loop, finally re-throws
388
+ }
389
+
390
+ if (eventName === 'payload') {
391
+ let parsedPayload;
392
+ try {
393
+ parsedPayload = JSON.parse(data);
394
+ } catch {
395
+ continue;
396
+ }
397
+ if (options.onPayload) {
398
+ await options.onPayload(parsedPayload);
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ // Stream ended without `event: done` — treat EOF as done.
405
+ if (options.onDone) options.onDone();
406
+ } catch (err) {
407
+ thrown = err;
408
+ } finally {
409
+ // Always notify onError (side-channel) and always throw — callers
410
+ // rely on Promise rejection as the canonical failure signal.
411
+ if (thrown) {
412
+ try { await reader.cancel(thrown); } catch { /* already cancelled */ }
413
+ if (options.onError) {
414
+ try { options.onError(thrown); } catch { /* observer can't break the throw */ }
415
+ }
416
+ try { reader.releaseLock(); } catch { /* already released */ }
417
+ throw thrown;
418
+ }
419
+ try { reader.releaseLock(); } catch { /* already released */ }
420
+ }
421
+ }
422
+
278
423
  /**
279
424
  * Update an existing scheduled message.
280
425
  *
281
426
  * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
282
427
  * the client soft-strips it from the patch and emits a `console.warn` —
283
428
  * 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.
429
+ * untouched. If `maxPayloadBytes` is configured, oversized JSON patches
430
+ * throw `PAYLOAD_TOO_LARGE_LOCAL`.
286
431
  *
287
432
  * @param {string} uuid - Task UUID.
288
433
  * @param {Object} updates - Fields to update.
@@ -420,21 +565,22 @@ class ReiClient {
420
565
  }
421
566
 
422
567
  /**
423
- * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
424
- * remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
425
- * caller a precise local error pointing at the size cap.
568
+ * Enforce the optional local request payload cap before encryption.
569
+ * By default there is no SDK-level request-size limit; runtime, proxy,
570
+ * database, and LLM-provider limits remain the deployer's boundary.
426
571
  *
427
572
  * @private
428
573
  * @param {string} bodyJson - `JSON.stringify(payload)`.
429
574
  * @param {string} methodName
430
575
  */
431
576
  _assertPayloadSize(bodyJson, methodName) {
577
+ if (this._maxPayloadBytes == null) return;
432
578
  const bytes = new TextEncoder().encode(bodyJson).length;
433
- if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
579
+ if (bytes > this._maxPayloadBytes) {
434
580
  throw makeLocalError(
435
581
  'PAYLOAD_TOO_LARGE_LOCAL',
436
- `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${PAYLOAD_LOCAL_MAX_BYTES} 字节`,
437
- { method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
582
+ `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${this._maxPayloadBytes} 字节`,
583
+ { method: methodName, actualBytes: bytes, limitBytes: this._maxPayloadBytes }
438
584
  );
439
585
  }
440
586
  }
@@ -527,4 +673,12 @@ class ReiClient {
527
673
  }
528
674
  }
529
675
 
676
+ function normalizeMaxPayloadBytes(value) {
677
+ if (value === undefined || value === null) return null;
678
+ if (!Number.isInteger(value) || value <= 0) {
679
+ throw new TypeError('[rei-standard-amsg-client] maxPayloadBytes must be a positive integer when set');
680
+ }
681
+ return value;
682
+ }
683
+
530
684
  export { ReiClient };
package/dist/index.mjs CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  isErrorPush
14
14
  } from "@rei-standard/amsg-shared";
15
15
  var AVATAR_URL_MAX_LENGTH = 2048;
16
- var PAYLOAD_LOCAL_MAX_BYTES = 3072;
17
16
  function makeLocalError(code, message, details) {
18
17
  const err = new Error(`[rei-standard-amsg-client] ${message}`);
19
18
  err.code = code;
@@ -45,6 +44,7 @@ var ReiClient = class {
45
44
  this._userKey = null;
46
45
  this._instantEncryption = instantEncryption;
47
46
  this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
47
+ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
48
48
  }
49
49
  /**
50
50
  * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
@@ -97,8 +97,8 @@ var ReiClient = class {
97
97
  *
98
98
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
99
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.
100
+ * schedule still ships, just without an avatar. If `maxPayloadBytes` is
101
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
102
102
  *
103
103
  * @param {Object} payload - Schedule message payload.
104
104
  * @returns {Promise<Object>} API response body.
@@ -143,8 +143,8 @@ var ReiClient = class {
143
143
  *
144
144
  * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
145
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.
146
+ * push still ships, just without an icon. If `maxPayloadBytes` is
147
+ * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
148
148
  *
149
149
  * @param {Object} payload - Instant message payload.
150
150
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -180,14 +180,153 @@ var ReiClient = class {
180
180
  });
181
181
  return res.json();
182
182
  }
183
+ /**
184
+ * Consume an instant SSE stream.
185
+ *
186
+ * Error semantics: any failure (network, protocol, abort, `onPayload`
187
+ * callback throwing) rejects the returned Promise. `options.onError`,
188
+ * when provided, is a side-channel notification (e.g. for logging or
189
+ * UI flashes) and fires before the rejection — it does not suppress
190
+ * it. Always wrap calls in `try / await` and treat the rejection as
191
+ * the canonical error path.
192
+ *
193
+ * @param {Object} payload - Instant message payload.
194
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
195
+ * @param {Object} options
196
+ * @param {Record<string, string>} [options.headers]
197
+ * @param {(payload: unknown) => Promise<void> | void} options.onPayload
198
+ * @param {(error: unknown) => void} [options.onError]
199
+ * @param {() => void} [options.onDone]
200
+ * @param {AbortSignal} [options.signal]
201
+ * @returns {Promise<void>}
202
+ */
203
+ async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
204
+ this._sanitizeAvatarUrl(payload);
205
+ const json = JSON.stringify(payload);
206
+ this._assertPayloadSize(json, "consumeInstantStream");
207
+ const headers = { "Content-Type": "application/json", ...options.headers || {} };
208
+ let body;
209
+ if (this._instantEncryption === false) {
210
+ body = json;
211
+ if (this._instantClientToken) {
212
+ headers["X-Client-Token"] = this._instantClientToken;
213
+ }
214
+ } else {
215
+ const encrypted = await this._encrypt(json);
216
+ headers["X-User-Id"] = this._userId;
217
+ headers["X-Payload-Encrypted"] = "true";
218
+ headers["X-Encryption-Version"] = "1";
219
+ body = JSON.stringify(encrypted);
220
+ }
221
+ const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
222
+ const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
223
+ method: "POST",
224
+ headers,
225
+ body,
226
+ signal: options.signal
227
+ });
228
+ if (!res.ok) {
229
+ const text = await res.text().catch(() => "");
230
+ throw new Error(`Instant request failed: ${res.status} ${text}`);
231
+ }
232
+ const contentType = res.headers.get("content-type") || "";
233
+ if (!contentType.includes("text/event-stream")) {
234
+ const text = await res.text().catch(() => "");
235
+ throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
236
+ }
237
+ if (!res.body) {
238
+ throw new Error("Response body is null");
239
+ }
240
+ const reader = res.body.getReader();
241
+ const decoder = new TextDecoder();
242
+ let buffer = "";
243
+ let thrown;
244
+ try {
245
+ while (true) {
246
+ const { done, value } = await reader.read();
247
+ if (done) break;
248
+ buffer += decoder.decode(value, { stream: true });
249
+ const parts = buffer.split("\n\n");
250
+ buffer = parts.pop() || "";
251
+ for (const part of parts) {
252
+ if (!part.trim()) continue;
253
+ let eventName = "message";
254
+ let data = "";
255
+ const lines = part.split("\n");
256
+ for (const line of lines) {
257
+ if (line.startsWith(":")) continue;
258
+ if (line.startsWith("event:")) {
259
+ eventName = line.slice(6).trim();
260
+ } else if (line.startsWith("data:")) {
261
+ const piece = line.slice(5).trim();
262
+ data = data ? `${data}
263
+ ${piece}` : piece;
264
+ }
265
+ }
266
+ if (eventName === "done") {
267
+ if (options.onDone) options.onDone();
268
+ return;
269
+ }
270
+ if (eventName === "error") {
271
+ let parsedErr;
272
+ try {
273
+ parsedErr = JSON.parse(data);
274
+ } catch {
275
+ parsedErr = { code: "PARSE_ERROR", message: data };
276
+ }
277
+ const err = new Error(parsedErr.message || "Stream error");
278
+ err.code = parsedErr.code;
279
+ thrown = err;
280
+ return;
281
+ }
282
+ if (eventName === "payload") {
283
+ let parsedPayload;
284
+ try {
285
+ parsedPayload = JSON.parse(data);
286
+ } catch {
287
+ continue;
288
+ }
289
+ if (options.onPayload) {
290
+ await options.onPayload(parsedPayload);
291
+ }
292
+ }
293
+ }
294
+ }
295
+ if (options.onDone) options.onDone();
296
+ } catch (err) {
297
+ thrown = err;
298
+ } finally {
299
+ if (thrown) {
300
+ try {
301
+ await reader.cancel(thrown);
302
+ } catch {
303
+ }
304
+ if (options.onError) {
305
+ try {
306
+ options.onError(thrown);
307
+ } catch {
308
+ }
309
+ }
310
+ try {
311
+ reader.releaseLock();
312
+ } catch {
313
+ }
314
+ throw thrown;
315
+ }
316
+ try {
317
+ reader.releaseLock();
318
+ } catch {
319
+ }
320
+ }
321
+ }
183
322
  /**
184
323
  * Update an existing scheduled message.
185
324
  *
186
325
  * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string),
187
326
  * the client soft-strips it from the patch and emits a `console.warn` —
188
327
  * 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.
328
+ * untouched. If `maxPayloadBytes` is configured, oversized JSON patches
329
+ * throw `PAYLOAD_TOO_LARGE_LOCAL`.
191
330
  *
192
331
  * @param {string} uuid - Task UUID.
193
332
  * @param {Object} updates - Fields to update.
@@ -308,21 +447,22 @@ var ReiClient = class {
308
447
  return false;
309
448
  }
310
449
  /**
311
- * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the
312
- * remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the
313
- * caller a precise local error pointing at the size cap.
450
+ * Enforce the optional local request payload cap before encryption.
451
+ * By default there is no SDK-level request-size limit; runtime, proxy,
452
+ * database, and LLM-provider limits remain the deployer's boundary.
314
453
  *
315
454
  * @private
316
455
  * @param {string} bodyJson - `JSON.stringify(payload)`.
317
456
  * @param {string} methodName
318
457
  */
319
458
  _assertPayloadSize(bodyJson, methodName) {
459
+ if (this._maxPayloadBytes == null) return;
320
460
  const bytes = new TextEncoder().encode(bodyJson).length;
321
- if (bytes > PAYLOAD_LOCAL_MAX_BYTES) {
461
+ if (bytes > this._maxPayloadBytes) {
322
462
  throw makeLocalError(
323
463
  "PAYLOAD_TOO_LARGE_LOCAL",
324
- `${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${PAYLOAD_LOCAL_MAX_BYTES} \u5B57\u8282`,
325
- { method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES }
464
+ `${methodName} payload \u4F53\u79EF ${bytes} \u5B57\u8282\u8D85\u8FC7\u672C\u5730\u4E0A\u9650 ${this._maxPayloadBytes} \u5B57\u8282`,
465
+ { method: methodName, actualBytes: bytes, limitBytes: this._maxPayloadBytes }
326
466
  );
327
467
  }
328
468
  }
@@ -400,6 +540,13 @@ var ReiClient = class {
400
540
  return arr;
401
541
  }
402
542
  };
543
+ function normalizeMaxPayloadBytes(value) {
544
+ if (value === void 0 || value === null) return null;
545
+ if (!Number.isInteger(value) || value <= 0) {
546
+ throw new TypeError("[rei-standard-amsg-client] maxPayloadBytes must be a positive integer when set");
547
+ }
548
+ return value;
549
+ }
403
550
  export {
404
551
  MESSAGE_KIND,
405
552
  MESSAGE_TYPE,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-client",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
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",
7
- "url": "https://github.com/Tosd0/ReiStandard",
7
+ "url": "git+https://github.com/Tosd0/ReiStandard.git",
8
8
  "directory": "packages/rei-standard-amsg/client"
9
9
  },
10
10
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "dependencies": {
36
- "@rei-standard/amsg-shared": "0.1.0"
36
+ "@rei-standard/amsg-shared": "0.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",