@rei-standard/amsg-client 2.6.0 → 2.8.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 +9 -0
- package/dist/index.cjs +33 -18
- package/dist/index.d.cts +91 -24
- package/dist/index.d.ts +91 -24
- package/dist/index.mjs +34 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -178,6 +178,13 @@ interface DeliverOptions {
|
|
|
178
178
|
// / X-Client-Token / Authorization
|
|
179
179
|
authorization?: string; // 透传成 Authorization header(与 sendInstant 对齐)
|
|
180
180
|
endpointPath?: string; // 默认 '/instant',可改 '/continue' 续跑
|
|
181
|
+
compressRequest?: boolean | { thresholdBytes?: number }; // 可选请求体 gzip。不传/falsy = 关(行为不变)
|
|
182
|
+
// true / {} = 开,阈值默认 16384 字节(16KB)
|
|
183
|
+
// { thresholdBytes: N } = 开 + 自定义阈值
|
|
184
|
+
// 仅当 body 超阈值且运行时有 CompressionStream 才压;
|
|
185
|
+
// 否则发明文(优雅降级,绝不抛)。压时发 gzip 字节 +
|
|
186
|
+
// 头 X-Amsg-Request-Encoding: gzip(非标准 Content-
|
|
187
|
+
// Encoding),由接收端 gunzip。SSE / JSON 两路通用。
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
interface ObservedDeliveryReceipt {
|
|
@@ -199,6 +206,8 @@ interface RawReadMeta {
|
|
|
199
206
|
|
|
200
207
|
> `onRawRead` 是诊断钩子:SSE 解析层默认丢弃 `:` 注释行(含每秒一发的 keepalive),出问题时无从判断「静默期里到底有没有字节到达」。挂上它就能在 raw `reader.read()` 这一层看到每次读到的原始字节与 keepalive 帧。不传则零开销、行为不变。
|
|
201
208
|
|
|
209
|
+
> `compressRequest` 用于大 body 上传:开启后,要发的 JSON 在上网线前 gzip(中文 + 重复结构压缩比很高),网线上字节小了就能在慢/不稳链路的发送超时之前传完,且上下文一字不动。仅当 body 超阈值且运行时支持 `CompressionStream` 才压,否则照常发明文;压缩出错也兜回明文,永不影响发送。压缩的是请求体,与响应 / `onChunk` / `onRawRead` 无关。接收端需按 `X-Amsg-Request-Encoding: gzip` 头自行解压。
|
|
210
|
+
|
|
202
211
|
### `delivery.mode` 必须显式选
|
|
203
212
|
|
|
204
213
|
| mode | 何时用 | outcome 取值 |
|
package/dist/index.cjs
CHANGED
|
@@ -36,7 +36,6 @@ module.exports = __toCommonJS(src_exports);
|
|
|
36
36
|
var import_amsg_shared = require("@rei-standard/amsg-shared");
|
|
37
37
|
var import_amsg_shared2 = require("@rei-standard/amsg-shared");
|
|
38
38
|
var TEXT_ENCODER = new TextEncoder();
|
|
39
|
-
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
40
39
|
function makeLocalError(code, message, details) {
|
|
41
40
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
42
41
|
err.code = code;
|
|
@@ -54,6 +53,25 @@ function classifyContentType(contentType) {
|
|
|
54
53
|
if (/^application\/[\w.+-]+\+json$/.test(main)) return "json";
|
|
55
54
|
return "unknown";
|
|
56
55
|
}
|
|
56
|
+
var COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
|
|
57
|
+
var COMPRESS_REQUEST_HEADER = "X-Amsg-Request-Encoding";
|
|
58
|
+
async function maybeCompressRequestBody(body, compressRequest) {
|
|
59
|
+
if (!compressRequest) return { body, header: null };
|
|
60
|
+
const threshold = typeof compressRequest === "object" && typeof compressRequest.thresholdBytes === "number" ? compressRequest.thresholdBytes : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
|
|
61
|
+
try {
|
|
62
|
+
if (typeof CompressionStream === "undefined") return { body, header: null };
|
|
63
|
+
const bytes = new TextEncoder().encode(body);
|
|
64
|
+
if (bytes.length <= threshold) return { body, header: null };
|
|
65
|
+
const gz = new Uint8Array(
|
|
66
|
+
await new Response(
|
|
67
|
+
new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))
|
|
68
|
+
).arrayBuffer()
|
|
69
|
+
);
|
|
70
|
+
return { body: gz, header: COMPRESS_REQUEST_HEADER };
|
|
71
|
+
} catch {
|
|
72
|
+
return { body, header: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
var ReiClient = class {
|
|
58
76
|
/**
|
|
59
77
|
* @param {ReiClientConfig} config
|
|
@@ -316,7 +334,8 @@ var ReiClient = class {
|
|
|
316
334
|
headers,
|
|
317
335
|
authorization,
|
|
318
336
|
endpointPath,
|
|
319
|
-
onRawRead
|
|
337
|
+
onRawRead,
|
|
338
|
+
compressRequest
|
|
320
339
|
} = opts;
|
|
321
340
|
if (!delivery || typeof delivery !== "object") {
|
|
322
341
|
throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
|
|
@@ -378,7 +397,8 @@ var ReiClient = class {
|
|
|
378
397
|
const result = await this._runInstantTransport(built, {
|
|
379
398
|
signal: internalAbort.signal,
|
|
380
399
|
onChunk: wrappedOnChunk,
|
|
381
|
-
onRawRead
|
|
400
|
+
onRawRead,
|
|
401
|
+
compressRequest
|
|
382
402
|
});
|
|
383
403
|
if (finalized) return;
|
|
384
404
|
transportEnded = true;
|
|
@@ -576,16 +596,7 @@ var ReiClient = class {
|
|
|
576
596
|
*/
|
|
577
597
|
_sanitizeAvatarUrl(target) {
|
|
578
598
|
if (!target || typeof target !== "object") return false;
|
|
579
|
-
const
|
|
580
|
-
if (value === void 0 || value === null) return false;
|
|
581
|
-
let reason = null;
|
|
582
|
-
if (typeof value !== "string") {
|
|
583
|
-
reason = "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
584
|
-
} else if (/^data:/i.test(value)) {
|
|
585
|
-
reason = "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
586
|
-
} else if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
587
|
-
reason = `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
588
|
-
}
|
|
599
|
+
const reason = (0, import_amsg_shared.validateAvatarUrl)(target.avatarUrl);
|
|
589
600
|
if (reason) {
|
|
590
601
|
console.warn("[rei-standard-amsg-client] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", reason);
|
|
591
602
|
target.avatarUrl = null;
|
|
@@ -657,21 +668,25 @@ var ReiClient = class {
|
|
|
657
668
|
*
|
|
658
669
|
* @private
|
|
659
670
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
660
|
-
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
|
|
671
|
+
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
|
|
661
672
|
* `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
|
|
673
|
+
* `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
|
|
662
674
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
663
675
|
*/
|
|
664
676
|
async _runInstantTransport(built, opts) {
|
|
665
|
-
const { signal, onChunk, onRawRead } = opts;
|
|
677
|
+
const { signal, onChunk, onRawRead, compressRequest } = opts;
|
|
666
678
|
const { url, headers, body } = built;
|
|
667
|
-
const
|
|
679
|
+
const { body: wireBody, header: compressionHeader } = await maybeCompressRequestBody(body, compressRequest);
|
|
680
|
+
const wireHeaders = compressionHeader ? { ...headers, [compressionHeader]: "gzip" } : headers;
|
|
681
|
+
const res = await fetch(url, { method: "POST", headers: wireHeaders, body: wireBody, signal });
|
|
668
682
|
if (!res.ok) {
|
|
669
683
|
const text2 = await res.text().catch(() => "");
|
|
670
684
|
const err = new Error(`Instant request failed: ${res.status} ${text2}`);
|
|
671
685
|
err.status = res.status;
|
|
672
686
|
throw err;
|
|
673
687
|
}
|
|
674
|
-
const
|
|
688
|
+
const rawContentType = res.headers.get("content-type");
|
|
689
|
+
const contentType = rawContentType || "";
|
|
675
690
|
const kind = classifyContentType(contentType);
|
|
676
691
|
if (kind === "sse") {
|
|
677
692
|
if (!res.body) throw new Error("Response body is null");
|
|
@@ -681,7 +696,7 @@ var ReiClient = class {
|
|
|
681
696
|
responseMeta: {
|
|
682
697
|
status: res.status,
|
|
683
698
|
contentEncoding: res.headers.get("content-encoding"),
|
|
684
|
-
contentType:
|
|
699
|
+
contentType: rawContentType
|
|
685
700
|
}
|
|
686
701
|
});
|
|
687
702
|
return { kind: "sse" };
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { base64UrlToBytes } from '@rei-standard/amsg-shared';
|
|
1
|
+
import { base64UrlToBytes, validateAvatarUrl } from '@rei-standard/amsg-shared';
|
|
2
2
|
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -201,6 +201,17 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
201
201
|
* silently drops. Use it to tell "connection alive but no business data" apart from "no bytes flowing
|
|
202
202
|
* at all" when diagnosing stalled streams. Purely observational: throws are swallowed and never affect
|
|
203
203
|
* transport. Not invoked for the JSON transport.
|
|
204
|
+
* @property {boolean | { thresholdBytes?: number }} [compressRequest] - Opt-in gzip of the request
|
|
205
|
+
* BODY before it is sent (applies to both the SSE and JSON transports — it compresses the request,
|
|
206
|
+
* not the response). Omit / falsy = OFF and behavior is fully unchanged (backward compatible).
|
|
207
|
+
* `true` or `{}` enables it at the default 16384-byte (16 KB) threshold; `{ thresholdBytes: N }`
|
|
208
|
+
* sets a custom threshold. When enabled, the body is gzip-compressed only if its UTF-8 byte length
|
|
209
|
+
* exceeds the threshold AND the runtime provides `CompressionStream`; otherwise it is sent as
|
|
210
|
+
* plaintext (graceful degradation, never throws). On compression the request gains the custom
|
|
211
|
+
* header `X-Amsg-Request-Encoding: gzip` (NOT standard `Content-Encoding`, which CDNs / proxies
|
|
212
|
+
* would auto-decompress and double-decode) and the body is the raw gzip bytes — the receiving
|
|
213
|
+
* worker is responsible for gunzipping. Use it when delivering large bodies over slow / flaky
|
|
214
|
+
* uplinks where a big upload can outrun the connection's send timeout.
|
|
204
215
|
*/
|
|
205
216
|
|
|
206
217
|
/**
|
|
@@ -220,14 +231,6 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
220
231
|
* @property {number} [status] - `res.status`. First call only.
|
|
221
232
|
*/
|
|
222
233
|
|
|
223
|
-
/**
|
|
224
|
-
* Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors
|
|
225
|
-
* `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side
|
|
226
|
-
* limits — kept in lockstep on purpose so client-side rejects match what
|
|
227
|
-
* the server would reject.
|
|
228
|
-
*/
|
|
229
|
-
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
230
|
-
|
|
231
234
|
function makeLocalError(code, message, details) {
|
|
232
235
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
233
236
|
err.code = code;
|
|
@@ -266,6 +269,68 @@ function classifyContentType(contentType) {
|
|
|
266
269
|
return 'unknown';
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Default size floor for request-body gzip: bodies at or below this are not
|
|
274
|
+
* worth compressing (the gzip header/overhead can outweigh the gain on tiny
|
|
275
|
+
* payloads). 16 KB matches the contract documented on `DeliverOptions.compressRequest`.
|
|
276
|
+
*/
|
|
277
|
+
const COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Custom request header used to mark a gzip-compressed body. Deliberately NOT
|
|
281
|
+
* the standard `Content-Encoding` — CDNs / reverse proxies (Cloudflare, etc.)
|
|
282
|
+
* auto-decompress `Content-Encoding: gzip` on the way in, which would double-
|
|
283
|
+
* decompress and corrupt the body. The receiving worker keys off this custom
|
|
284
|
+
* header to know it must gunzip the body itself.
|
|
285
|
+
*/
|
|
286
|
+
const COMPRESS_REQUEST_HEADER = 'X-Amsg-Request-Encoding';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Optionally gzip a request body string before it hits `fetch`.
|
|
290
|
+
*
|
|
291
|
+
* Pure optimization with graceful degradation: returns the original plaintext
|
|
292
|
+
* body (and no extra header) whenever compression is disabled, the body is at
|
|
293
|
+
* or below the threshold, the runtime lacks `CompressionStream`, or anything
|
|
294
|
+
* throws. The wire bytes shrink (Chinese / repetitive JSON compresses ~5-8x)
|
|
295
|
+
* so large uploads finish before flaky links time out — without dropping any
|
|
296
|
+
* context. Decompression is the receiving worker's job (keyed off
|
|
297
|
+
* `X-Amsg-Request-Encoding: gzip`).
|
|
298
|
+
*
|
|
299
|
+
* @param {string} body - The already-serialized request body (plaintext JSON).
|
|
300
|
+
* @param {boolean | { thresholdBytes?: number } | undefined} compressRequest
|
|
301
|
+
* `undefined`/falsy ⇒ disabled (no-op, backward compatible). `true` / `{}` ⇒
|
|
302
|
+
* enabled at the 16 KB default. `{ thresholdBytes: N }` ⇒ enabled at N bytes.
|
|
303
|
+
* @returns {Promise<{ body: string | Uint8Array, header: string | null }>}
|
|
304
|
+
* `header` is the gzip marker header name to set when compression happened,
|
|
305
|
+
* or `null` to send plaintext with no extra header.
|
|
306
|
+
*/
|
|
307
|
+
async function maybeCompressRequestBody(body, compressRequest) {
|
|
308
|
+
// Disabled / no opt-in ⇒ behavior unchanged.
|
|
309
|
+
if (!compressRequest) return { body, header: null };
|
|
310
|
+
|
|
311
|
+
const threshold =
|
|
312
|
+
typeof compressRequest === 'object' && typeof compressRequest.thresholdBytes === 'number'
|
|
313
|
+
? compressRequest.thresholdBytes
|
|
314
|
+
: COMPRESS_REQUEST_DEFAULT_THRESHOLD;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
if (typeof CompressionStream === 'undefined') return { body, header: null };
|
|
318
|
+
|
|
319
|
+
const bytes = new TextEncoder().encode(body);
|
|
320
|
+
if (bytes.length <= threshold) return { body, header: null };
|
|
321
|
+
|
|
322
|
+
const gz = new Uint8Array(
|
|
323
|
+
await new Response(
|
|
324
|
+
new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
|
|
325
|
+
).arrayBuffer()
|
|
326
|
+
);
|
|
327
|
+
return { body: gz, header: COMPRESS_REQUEST_HEADER };
|
|
328
|
+
} catch {
|
|
329
|
+
// Compression is an optimization, never a failure mode: fall back to plaintext.
|
|
330
|
+
return { body, header: null };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
269
334
|
class ReiClient {
|
|
270
335
|
/**
|
|
271
336
|
* @param {ReiClientConfig} config
|
|
@@ -560,6 +625,7 @@ class ReiClient {
|
|
|
560
625
|
const {
|
|
561
626
|
delivery, timeoutMs, onChunk, postTransportGraceMs,
|
|
562
627
|
signal, headers, authorization, endpointPath, onRawRead,
|
|
628
|
+
compressRequest,
|
|
563
629
|
} = opts;
|
|
564
630
|
|
|
565
631
|
if (!delivery || typeof delivery !== 'object') {
|
|
@@ -653,6 +719,7 @@ class ReiClient {
|
|
|
653
719
|
signal: internalAbort.signal,
|
|
654
720
|
onChunk: wrappedOnChunk,
|
|
655
721
|
onRawRead,
|
|
722
|
+
compressRequest,
|
|
656
723
|
});
|
|
657
724
|
if (finalized) return;
|
|
658
725
|
transportEnded = true;
|
|
@@ -916,16 +983,7 @@ class ReiClient {
|
|
|
916
983
|
*/
|
|
917
984
|
_sanitizeAvatarUrl(target) {
|
|
918
985
|
if (!target || typeof target !== 'object') return false;
|
|
919
|
-
const
|
|
920
|
-
if (value === undefined || value === null) return false;
|
|
921
|
-
let reason = null;
|
|
922
|
-
if (typeof value !== 'string') {
|
|
923
|
-
reason = 'avatarUrl 必须是字符串';
|
|
924
|
-
} else if (/^data:/i.test(value)) {
|
|
925
|
-
reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
|
|
926
|
-
} else if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
927
|
-
reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
|
|
928
|
-
}
|
|
986
|
+
const reason = validateAvatarUrl(target.avatarUrl);
|
|
929
987
|
if (reason) {
|
|
930
988
|
console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
|
|
931
989
|
target.avatarUrl = null;
|
|
@@ -1005,15 +1063,23 @@ class ReiClient {
|
|
|
1005
1063
|
*
|
|
1006
1064
|
* @private
|
|
1007
1065
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
1008
|
-
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
|
|
1066
|
+
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
|
|
1009
1067
|
* `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
|
|
1068
|
+
* `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
|
|
1010
1069
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
1011
1070
|
*/
|
|
1012
1071
|
async _runInstantTransport(built, opts) {
|
|
1013
|
-
const { signal, onChunk, onRawRead } = opts;
|
|
1072
|
+
const { signal, onChunk, onRawRead, compressRequest } = opts;
|
|
1014
1073
|
const { url, headers, body } = built;
|
|
1015
1074
|
|
|
1016
|
-
|
|
1075
|
+
// Optionally gzip the request body (opt-in, graceful fallback to plaintext).
|
|
1076
|
+
const { body: wireBody, header: compressionHeader } =
|
|
1077
|
+
await maybeCompressRequestBody(body, compressRequest);
|
|
1078
|
+
const wireHeaders = compressionHeader
|
|
1079
|
+
? { ...headers, [compressionHeader]: 'gzip' }
|
|
1080
|
+
: headers;
|
|
1081
|
+
|
|
1082
|
+
const res = await fetch(url, { method: 'POST', headers: wireHeaders, body: wireBody, signal });
|
|
1017
1083
|
|
|
1018
1084
|
if (!res.ok) {
|
|
1019
1085
|
const text = await res.text().catch(() => '');
|
|
@@ -1022,7 +1088,8 @@ class ReiClient {
|
|
|
1022
1088
|
throw err;
|
|
1023
1089
|
}
|
|
1024
1090
|
|
|
1025
|
-
const
|
|
1091
|
+
const rawContentType = res.headers.get('content-type');
|
|
1092
|
+
const contentType = rawContentType || '';
|
|
1026
1093
|
const kind = classifyContentType(contentType);
|
|
1027
1094
|
if (kind === 'sse') {
|
|
1028
1095
|
if (!res.body) throw new Error('Response body is null');
|
|
@@ -1032,7 +1099,7 @@ class ReiClient {
|
|
|
1032
1099
|
responseMeta: {
|
|
1033
1100
|
status: res.status,
|
|
1034
1101
|
contentEncoding: res.headers.get('content-encoding'),
|
|
1035
|
-
contentType:
|
|
1102
|
+
contentType: rawContentType,
|
|
1036
1103
|
},
|
|
1037
1104
|
});
|
|
1038
1105
|
return { kind: 'sse' };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { base64UrlToBytes } from '@rei-standard/amsg-shared';
|
|
1
|
+
import { base64UrlToBytes, validateAvatarUrl } from '@rei-standard/amsg-shared';
|
|
2
2
|
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -201,6 +201,17 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
201
201
|
* silently drops. Use it to tell "connection alive but no business data" apart from "no bytes flowing
|
|
202
202
|
* at all" when diagnosing stalled streams. Purely observational: throws are swallowed and never affect
|
|
203
203
|
* transport. Not invoked for the JSON transport.
|
|
204
|
+
* @property {boolean | { thresholdBytes?: number }} [compressRequest] - Opt-in gzip of the request
|
|
205
|
+
* BODY before it is sent (applies to both the SSE and JSON transports — it compresses the request,
|
|
206
|
+
* not the response). Omit / falsy = OFF and behavior is fully unchanged (backward compatible).
|
|
207
|
+
* `true` or `{}` enables it at the default 16384-byte (16 KB) threshold; `{ thresholdBytes: N }`
|
|
208
|
+
* sets a custom threshold. When enabled, the body is gzip-compressed only if its UTF-8 byte length
|
|
209
|
+
* exceeds the threshold AND the runtime provides `CompressionStream`; otherwise it is sent as
|
|
210
|
+
* plaintext (graceful degradation, never throws). On compression the request gains the custom
|
|
211
|
+
* header `X-Amsg-Request-Encoding: gzip` (NOT standard `Content-Encoding`, which CDNs / proxies
|
|
212
|
+
* would auto-decompress and double-decode) and the body is the raw gzip bytes — the receiving
|
|
213
|
+
* worker is responsible for gunzipping. Use it when delivering large bodies over slow / flaky
|
|
214
|
+
* uplinks where a big upload can outrun the connection's send timeout.
|
|
204
215
|
*/
|
|
205
216
|
|
|
206
217
|
/**
|
|
@@ -220,14 +231,6 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
220
231
|
* @property {number} [status] - `res.status`. First call only.
|
|
221
232
|
*/
|
|
222
233
|
|
|
223
|
-
/**
|
|
224
|
-
* Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors
|
|
225
|
-
* `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side
|
|
226
|
-
* limits — kept in lockstep on purpose so client-side rejects match what
|
|
227
|
-
* the server would reject.
|
|
228
|
-
*/
|
|
229
|
-
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
230
|
-
|
|
231
234
|
function makeLocalError(code, message, details) {
|
|
232
235
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
233
236
|
err.code = code;
|
|
@@ -266,6 +269,68 @@ function classifyContentType(contentType) {
|
|
|
266
269
|
return 'unknown';
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Default size floor for request-body gzip: bodies at or below this are not
|
|
274
|
+
* worth compressing (the gzip header/overhead can outweigh the gain on tiny
|
|
275
|
+
* payloads). 16 KB matches the contract documented on `DeliverOptions.compressRequest`.
|
|
276
|
+
*/
|
|
277
|
+
const COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Custom request header used to mark a gzip-compressed body. Deliberately NOT
|
|
281
|
+
* the standard `Content-Encoding` — CDNs / reverse proxies (Cloudflare, etc.)
|
|
282
|
+
* auto-decompress `Content-Encoding: gzip` on the way in, which would double-
|
|
283
|
+
* decompress and corrupt the body. The receiving worker keys off this custom
|
|
284
|
+
* header to know it must gunzip the body itself.
|
|
285
|
+
*/
|
|
286
|
+
const COMPRESS_REQUEST_HEADER = 'X-Amsg-Request-Encoding';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Optionally gzip a request body string before it hits `fetch`.
|
|
290
|
+
*
|
|
291
|
+
* Pure optimization with graceful degradation: returns the original plaintext
|
|
292
|
+
* body (and no extra header) whenever compression is disabled, the body is at
|
|
293
|
+
* or below the threshold, the runtime lacks `CompressionStream`, or anything
|
|
294
|
+
* throws. The wire bytes shrink (Chinese / repetitive JSON compresses ~5-8x)
|
|
295
|
+
* so large uploads finish before flaky links time out — without dropping any
|
|
296
|
+
* context. Decompression is the receiving worker's job (keyed off
|
|
297
|
+
* `X-Amsg-Request-Encoding: gzip`).
|
|
298
|
+
*
|
|
299
|
+
* @param {string} body - The already-serialized request body (plaintext JSON).
|
|
300
|
+
* @param {boolean | { thresholdBytes?: number } | undefined} compressRequest
|
|
301
|
+
* `undefined`/falsy ⇒ disabled (no-op, backward compatible). `true` / `{}` ⇒
|
|
302
|
+
* enabled at the 16 KB default. `{ thresholdBytes: N }` ⇒ enabled at N bytes.
|
|
303
|
+
* @returns {Promise<{ body: string | Uint8Array, header: string | null }>}
|
|
304
|
+
* `header` is the gzip marker header name to set when compression happened,
|
|
305
|
+
* or `null` to send plaintext with no extra header.
|
|
306
|
+
*/
|
|
307
|
+
async function maybeCompressRequestBody(body, compressRequest) {
|
|
308
|
+
// Disabled / no opt-in ⇒ behavior unchanged.
|
|
309
|
+
if (!compressRequest) return { body, header: null };
|
|
310
|
+
|
|
311
|
+
const threshold =
|
|
312
|
+
typeof compressRequest === 'object' && typeof compressRequest.thresholdBytes === 'number'
|
|
313
|
+
? compressRequest.thresholdBytes
|
|
314
|
+
: COMPRESS_REQUEST_DEFAULT_THRESHOLD;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
if (typeof CompressionStream === 'undefined') return { body, header: null };
|
|
318
|
+
|
|
319
|
+
const bytes = new TextEncoder().encode(body);
|
|
320
|
+
if (bytes.length <= threshold) return { body, header: null };
|
|
321
|
+
|
|
322
|
+
const gz = new Uint8Array(
|
|
323
|
+
await new Response(
|
|
324
|
+
new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
|
|
325
|
+
).arrayBuffer()
|
|
326
|
+
);
|
|
327
|
+
return { body: gz, header: COMPRESS_REQUEST_HEADER };
|
|
328
|
+
} catch {
|
|
329
|
+
// Compression is an optimization, never a failure mode: fall back to plaintext.
|
|
330
|
+
return { body, header: null };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
269
334
|
class ReiClient {
|
|
270
335
|
/**
|
|
271
336
|
* @param {ReiClientConfig} config
|
|
@@ -560,6 +625,7 @@ class ReiClient {
|
|
|
560
625
|
const {
|
|
561
626
|
delivery, timeoutMs, onChunk, postTransportGraceMs,
|
|
562
627
|
signal, headers, authorization, endpointPath, onRawRead,
|
|
628
|
+
compressRequest,
|
|
563
629
|
} = opts;
|
|
564
630
|
|
|
565
631
|
if (!delivery || typeof delivery !== 'object') {
|
|
@@ -653,6 +719,7 @@ class ReiClient {
|
|
|
653
719
|
signal: internalAbort.signal,
|
|
654
720
|
onChunk: wrappedOnChunk,
|
|
655
721
|
onRawRead,
|
|
722
|
+
compressRequest,
|
|
656
723
|
});
|
|
657
724
|
if (finalized) return;
|
|
658
725
|
transportEnded = true;
|
|
@@ -916,16 +983,7 @@ class ReiClient {
|
|
|
916
983
|
*/
|
|
917
984
|
_sanitizeAvatarUrl(target) {
|
|
918
985
|
if (!target || typeof target !== 'object') return false;
|
|
919
|
-
const
|
|
920
|
-
if (value === undefined || value === null) return false;
|
|
921
|
-
let reason = null;
|
|
922
|
-
if (typeof value !== 'string') {
|
|
923
|
-
reason = 'avatarUrl 必须是字符串';
|
|
924
|
-
} else if (/^data:/i.test(value)) {
|
|
925
|
-
reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
|
|
926
|
-
} else if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
927
|
-
reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
|
|
928
|
-
}
|
|
986
|
+
const reason = validateAvatarUrl(target.avatarUrl);
|
|
929
987
|
if (reason) {
|
|
930
988
|
console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason);
|
|
931
989
|
target.avatarUrl = null;
|
|
@@ -1005,15 +1063,23 @@ class ReiClient {
|
|
|
1005
1063
|
*
|
|
1006
1064
|
* @private
|
|
1007
1065
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
1008
|
-
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
|
|
1066
|
+
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
|
|
1009
1067
|
* `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
|
|
1068
|
+
* `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
|
|
1010
1069
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
1011
1070
|
*/
|
|
1012
1071
|
async _runInstantTransport(built, opts) {
|
|
1013
|
-
const { signal, onChunk, onRawRead } = opts;
|
|
1072
|
+
const { signal, onChunk, onRawRead, compressRequest } = opts;
|
|
1014
1073
|
const { url, headers, body } = built;
|
|
1015
1074
|
|
|
1016
|
-
|
|
1075
|
+
// Optionally gzip the request body (opt-in, graceful fallback to plaintext).
|
|
1076
|
+
const { body: wireBody, header: compressionHeader } =
|
|
1077
|
+
await maybeCompressRequestBody(body, compressRequest);
|
|
1078
|
+
const wireHeaders = compressionHeader
|
|
1079
|
+
? { ...headers, [compressionHeader]: 'gzip' }
|
|
1080
|
+
: headers;
|
|
1081
|
+
|
|
1082
|
+
const res = await fetch(url, { method: 'POST', headers: wireHeaders, body: wireBody, signal });
|
|
1017
1083
|
|
|
1018
1084
|
if (!res.ok) {
|
|
1019
1085
|
const text = await res.text().catch(() => '');
|
|
@@ -1022,7 +1088,8 @@ class ReiClient {
|
|
|
1022
1088
|
throw err;
|
|
1023
1089
|
}
|
|
1024
1090
|
|
|
1025
|
-
const
|
|
1091
|
+
const rawContentType = res.headers.get('content-type');
|
|
1092
|
+
const contentType = rawContentType || '';
|
|
1026
1093
|
const kind = classifyContentType(contentType);
|
|
1027
1094
|
if (kind === 'sse') {
|
|
1028
1095
|
if (!res.body) throw new Error('Response body is null');
|
|
@@ -1032,7 +1099,7 @@ class ReiClient {
|
|
|
1032
1099
|
responseMeta: {
|
|
1033
1100
|
status: res.status,
|
|
1034
1101
|
contentEncoding: res.headers.get('content-encoding'),
|
|
1035
|
-
contentType:
|
|
1102
|
+
contentType: rawContentType,
|
|
1036
1103
|
},
|
|
1037
1104
|
});
|
|
1038
1105
|
return { kind: 'sse' };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.js
|
|
2
|
-
import { base64UrlToBytes } from "@rei-standard/amsg-shared";
|
|
2
|
+
import { base64UrlToBytes, validateAvatarUrl } from "@rei-standard/amsg-shared";
|
|
3
3
|
import {
|
|
4
4
|
MESSAGE_KIND,
|
|
5
5
|
MESSAGE_TYPE,
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
isErrorPush
|
|
15
15
|
} from "@rei-standard/amsg-shared";
|
|
16
16
|
var TEXT_ENCODER = new TextEncoder();
|
|
17
|
-
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
18
17
|
function makeLocalError(code, message, details) {
|
|
19
18
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
20
19
|
err.code = code;
|
|
@@ -32,6 +31,25 @@ function classifyContentType(contentType) {
|
|
|
32
31
|
if (/^application\/[\w.+-]+\+json$/.test(main)) return "json";
|
|
33
32
|
return "unknown";
|
|
34
33
|
}
|
|
34
|
+
var COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
|
|
35
|
+
var COMPRESS_REQUEST_HEADER = "X-Amsg-Request-Encoding";
|
|
36
|
+
async function maybeCompressRequestBody(body, compressRequest) {
|
|
37
|
+
if (!compressRequest) return { body, header: null };
|
|
38
|
+
const threshold = typeof compressRequest === "object" && typeof compressRequest.thresholdBytes === "number" ? compressRequest.thresholdBytes : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
|
|
39
|
+
try {
|
|
40
|
+
if (typeof CompressionStream === "undefined") return { body, header: null };
|
|
41
|
+
const bytes = new TextEncoder().encode(body);
|
|
42
|
+
if (bytes.length <= threshold) return { body, header: null };
|
|
43
|
+
const gz = new Uint8Array(
|
|
44
|
+
await new Response(
|
|
45
|
+
new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))
|
|
46
|
+
).arrayBuffer()
|
|
47
|
+
);
|
|
48
|
+
return { body: gz, header: COMPRESS_REQUEST_HEADER };
|
|
49
|
+
} catch {
|
|
50
|
+
return { body, header: null };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
35
53
|
var ReiClient = class {
|
|
36
54
|
/**
|
|
37
55
|
* @param {ReiClientConfig} config
|
|
@@ -294,7 +312,8 @@ var ReiClient = class {
|
|
|
294
312
|
headers,
|
|
295
313
|
authorization,
|
|
296
314
|
endpointPath,
|
|
297
|
-
onRawRead
|
|
315
|
+
onRawRead,
|
|
316
|
+
compressRequest
|
|
298
317
|
} = opts;
|
|
299
318
|
if (!delivery || typeof delivery !== "object") {
|
|
300
319
|
throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
|
|
@@ -356,7 +375,8 @@ var ReiClient = class {
|
|
|
356
375
|
const result = await this._runInstantTransport(built, {
|
|
357
376
|
signal: internalAbort.signal,
|
|
358
377
|
onChunk: wrappedOnChunk,
|
|
359
|
-
onRawRead
|
|
378
|
+
onRawRead,
|
|
379
|
+
compressRequest
|
|
360
380
|
});
|
|
361
381
|
if (finalized) return;
|
|
362
382
|
transportEnded = true;
|
|
@@ -554,16 +574,7 @@ var ReiClient = class {
|
|
|
554
574
|
*/
|
|
555
575
|
_sanitizeAvatarUrl(target) {
|
|
556
576
|
if (!target || typeof target !== "object") return false;
|
|
557
|
-
const
|
|
558
|
-
if (value === void 0 || value === null) return false;
|
|
559
|
-
let reason = null;
|
|
560
|
-
if (typeof value !== "string") {
|
|
561
|
-
reason = "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
562
|
-
} else if (/^data:/i.test(value)) {
|
|
563
|
-
reason = "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
564
|
-
} else if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
565
|
-
reason = `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
566
|
-
}
|
|
577
|
+
const reason = validateAvatarUrl(target.avatarUrl);
|
|
567
578
|
if (reason) {
|
|
568
579
|
console.warn("[rei-standard-amsg-client] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", reason);
|
|
569
580
|
target.avatarUrl = null;
|
|
@@ -635,21 +646,25 @@ var ReiClient = class {
|
|
|
635
646
|
*
|
|
636
647
|
* @private
|
|
637
648
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
638
|
-
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
|
|
649
|
+
* @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
|
|
639
650
|
* `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
|
|
651
|
+
* `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
|
|
640
652
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
641
653
|
*/
|
|
642
654
|
async _runInstantTransport(built, opts) {
|
|
643
|
-
const { signal, onChunk, onRawRead } = opts;
|
|
655
|
+
const { signal, onChunk, onRawRead, compressRequest } = opts;
|
|
644
656
|
const { url, headers, body } = built;
|
|
645
|
-
const
|
|
657
|
+
const { body: wireBody, header: compressionHeader } = await maybeCompressRequestBody(body, compressRequest);
|
|
658
|
+
const wireHeaders = compressionHeader ? { ...headers, [compressionHeader]: "gzip" } : headers;
|
|
659
|
+
const res = await fetch(url, { method: "POST", headers: wireHeaders, body: wireBody, signal });
|
|
646
660
|
if (!res.ok) {
|
|
647
661
|
const text2 = await res.text().catch(() => "");
|
|
648
662
|
const err = new Error(`Instant request failed: ${res.status} ${text2}`);
|
|
649
663
|
err.status = res.status;
|
|
650
664
|
throw err;
|
|
651
665
|
}
|
|
652
|
-
const
|
|
666
|
+
const rawContentType = res.headers.get("content-type");
|
|
667
|
+
const contentType = rawContentType || "";
|
|
653
668
|
const kind = classifyContentType(contentType);
|
|
654
669
|
if (kind === "sse") {
|
|
655
670
|
if (!res.body) throw new Error("Response body is null");
|
|
@@ -659,7 +674,7 @@ var ReiClient = class {
|
|
|
659
674
|
responseMeta: {
|
|
660
675
|
status: res.status,
|
|
661
676
|
contentEncoding: res.headers.get("content-encoding"),
|
|
662
|
-
contentType:
|
|
677
|
+
contentType: rawContentType
|
|
663
678
|
}
|
|
664
679
|
});
|
|
665
680
|
return { kind: "sse" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rei-standard/amsg-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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",
|
|
@@ -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.3.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"tsup": "^8.0.0",
|