@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 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 value = target.avatarUrl;
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 res = await fetch(url, { method: "POST", headers, body, signal });
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 contentType = res.headers.get("content-type") || "";
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: res.headers.get("content-type")
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 value = target.avatarUrl;
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
- const res = await fetch(url, { method: 'POST', headers, body, signal });
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 contentType = res.headers.get('content-type') || '';
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: res.headers.get('content-type'),
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 value = target.avatarUrl;
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
- const res = await fetch(url, { method: 'POST', headers, body, signal });
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 contentType = res.headers.get('content-type') || '';
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: res.headers.get('content-type'),
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 value = target.avatarUrl;
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 res = await fetch(url, { method: "POST", headers, body, signal });
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 contentType = res.headers.get("content-type") || "";
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: res.headers.get("content-type")
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.6.0",
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.2.0"
36
+ "@rei-standard/amsg-shared": "^0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "tsup": "^8.0.0",