@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 +57 -6
- package/dist/index.cjs +160 -13
- package/dist/index.d.cts +175 -21
- package/dist/index.d.ts +175 -21
- package/dist/index.mjs +160 -13
- package/package.json +3 -3
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
|
-
|
|
182
|
+
**两个常见坑**:
|
|
179
183
|
|
|
180
184
|
- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
|
|
181
185
|
- 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
|
|
182
186
|
|
|
183
|
-
###
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
123
|
-
*
|
|
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.
|
|
169
|
-
*
|
|
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.
|
|
212
|
-
*
|
|
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
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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 >
|
|
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 ${
|
|
347
|
-
{ method: methodName, actualBytes: bytes, limitBytes:
|
|
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.
|
|
187
|
-
*
|
|
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.
|
|
236
|
-
*
|
|
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.
|
|
285
|
-
*
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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 >
|
|
579
|
+
if (bytes > this._maxPayloadBytes) {
|
|
434
580
|
throw makeLocalError(
|
|
435
581
|
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
436
|
-
`${methodName} payload 体积 ${bytes} 字节超过本地上限 ${
|
|
437
|
-
{ method: methodName, actualBytes: bytes, limitBytes:
|
|
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.
|
|
187
|
-
*
|
|
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.
|
|
236
|
-
*
|
|
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.
|
|
285
|
-
*
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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 >
|
|
579
|
+
if (bytes > this._maxPayloadBytes) {
|
|
434
580
|
throw makeLocalError(
|
|
435
581
|
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
436
|
-
`${methodName} payload 体积 ${bytes} 字节超过本地上限 ${
|
|
437
|
-
{ method: methodName, actualBytes: bytes, limitBytes:
|
|
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.
|
|
101
|
-
*
|
|
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.
|
|
147
|
-
*
|
|
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.
|
|
190
|
-
*
|
|
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
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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 >
|
|
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 ${
|
|
325
|
-
{ method: methodName, actualBytes: bytes, limitBytes:
|
|
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
|
+
"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.
|
|
36
|
+
"@rei-standard/amsg-shared": "0.2.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"tsup": "^8.0.0",
|