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