@rei-standard/amsg-client 2.5.0 → 2.7.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
@@ -165,6 +165,8 @@ interface DeliverOptions {
165
165
 
166
166
  timeoutMs: number; // 总预算(含 transport + grace)
167
167
  onChunk?: (payload: unknown) => Promise<void> | void; // 可选 SSE 每帧钩子,抛错被吞
168
+ onRawRead?: (meta: RawReadMeta) => void; // 可选 SSE 原始读遥测,排查链路用;抛错被吞
169
+ // 每次 reader.read() 后触发,保留 ':' 注释行
168
170
  postTransportGraceMs?: number; // transport 结束后等观察的 grace
169
171
  // 默认 = min(remaining, max(5000, timeoutMs * 0.1))
170
172
  // cancel 路径下生效的是 grace / 2
@@ -176,6 +178,13 @@ interface DeliverOptions {
176
178
  // / X-Client-Token / Authorization
177
179
  authorization?: string; // 透传成 Authorization header(与 sendInstant 对齐)
178
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 两路通用。
179
188
  }
180
189
 
181
190
  interface ObservedDeliveryReceipt {
@@ -183,8 +192,22 @@ interface ObservedDeliveryReceipt {
183
192
  sessionId?: string; // ↑
184
193
  channel?: string; // 'sw' / 'ipc' / 'native' / 'poll' / 任意诊断 label
185
194
  }
195
+
196
+ interface RawReadMeta {
197
+ ts: number; // Date.now()
198
+ byteLength: number; // 本次 reader.read() 拿到的字节数
199
+ done: boolean; // 流是否结束
200
+ textPreview: string; // 本次数据解码后的前 120 字符,保留 ':' keepalive 注释行
201
+ status?: number; // 仅首帧带:响应状态码
202
+ contentEncoding?: string | null; // 仅首帧带:响应 Content-Encoding(查是否被边缘压缩)
203
+ contentType?: string | null; // 仅首帧带
204
+ }
186
205
  ```
187
206
 
207
+ > `onRawRead` 是诊断钩子:SSE 解析层默认丢弃 `:` 注释行(含每秒一发的 keepalive),出问题时无从判断「静默期里到底有没有字节到达」。挂上它就能在 raw `reader.read()` 这一层看到每次读到的原始字节与 keepalive 帧。不传则零开销、行为不变。
208
+
209
+ > `compressRequest` 用于大 body 上传:开启后,要发的 JSON 在上网线前 gzip(中文 + 重复结构压缩比很高),网线上字节小了就能在慢/不稳链路的发送超时之前传完,且上下文一字不动。仅当 body 超阈值且运行时支持 `CompressionStream` 才压,否则照常发明文;压缩出错也兜回明文,永不影响发送。压缩的是请求体,与响应 / `onChunk` / `onRawRead` 无关。接收端需按 `X-Amsg-Request-Encoding: gzip` 头自行解压。
210
+
188
211
  ### `delivery.mode` 必须显式选
189
212
 
190
213
  | mode | 何时用 | outcome 取值 |
package/dist/index.cjs CHANGED
@@ -54,6 +54,25 @@ function classifyContentType(contentType) {
54
54
  if (/^application\/[\w.+-]+\+json$/.test(main)) return "json";
55
55
  return "unknown";
56
56
  }
57
+ var COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
58
+ var COMPRESS_REQUEST_HEADER = "X-Amsg-Request-Encoding";
59
+ async function maybeCompressRequestBody(body, compressRequest) {
60
+ if (!compressRequest) return { body, header: null };
61
+ const threshold = typeof compressRequest === "object" && typeof compressRequest.thresholdBytes === "number" ? compressRequest.thresholdBytes : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
62
+ try {
63
+ if (typeof CompressionStream === "undefined") return { body, header: null };
64
+ const bytes = new TextEncoder().encode(body);
65
+ if (bytes.length <= threshold) return { body, header: null };
66
+ const gz = new Uint8Array(
67
+ await new Response(
68
+ new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))
69
+ ).arrayBuffer()
70
+ );
71
+ return { body: gz, header: COMPRESS_REQUEST_HEADER };
72
+ } catch {
73
+ return { body, header: null };
74
+ }
75
+ }
57
76
  var ReiClient = class {
58
77
  /**
59
78
  * @param {ReiClientConfig} config
@@ -315,7 +334,9 @@ var ReiClient = class {
315
334
  signal,
316
335
  headers,
317
336
  authorization,
318
- endpointPath
337
+ endpointPath,
338
+ onRawRead,
339
+ compressRequest
319
340
  } = opts;
320
341
  if (!delivery || typeof delivery !== "object") {
321
342
  throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
@@ -376,7 +397,9 @@ var ReiClient = class {
376
397
  try {
377
398
  const result = await this._runInstantTransport(built, {
378
399
  signal: internalAbort.signal,
379
- onChunk: wrappedOnChunk
400
+ onChunk: wrappedOnChunk,
401
+ onRawRead,
402
+ compressRequest
380
403
  });
381
404
  if (finalized) return;
382
405
  transportEnded = true;
@@ -655,13 +678,17 @@ var ReiClient = class {
655
678
  *
656
679
  * @private
657
680
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
658
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
681
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
682
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
683
+ * `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
659
684
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
660
685
  */
661
686
  async _runInstantTransport(built, opts) {
662
- const { signal, onChunk } = opts;
687
+ const { signal, onChunk, onRawRead, compressRequest } = opts;
663
688
  const { url, headers, body } = built;
664
- const res = await fetch(url, { method: "POST", headers, body, signal });
689
+ const { body: wireBody, header: compressionHeader } = await maybeCompressRequestBody(body, compressRequest);
690
+ const wireHeaders = compressionHeader ? { ...headers, [compressionHeader]: "gzip" } : headers;
691
+ const res = await fetch(url, { method: "POST", headers: wireHeaders, body: wireBody, signal });
665
692
  if (!res.ok) {
666
693
  const text2 = await res.text().catch(() => "");
667
694
  const err = new Error(`Instant request failed: ${res.status} ${text2}`);
@@ -672,7 +699,15 @@ var ReiClient = class {
672
699
  const kind = classifyContentType(contentType);
673
700
  if (kind === "sse") {
674
701
  if (!res.body) throw new Error("Response body is null");
675
- await this._consumeSseStream(res, { onPayload: onChunk });
702
+ await this._consumeSseStream(res, {
703
+ onPayload: onChunk,
704
+ onRawRead,
705
+ responseMeta: {
706
+ status: res.status,
707
+ contentEncoding: res.headers.get("content-encoding"),
708
+ contentType: res.headers.get("content-type")
709
+ }
710
+ });
676
711
  return { kind: "sse" };
677
712
  }
678
713
  if (kind === "json") {
@@ -689,15 +724,47 @@ var ReiClient = class {
689
724
  *
690
725
  * @private
691
726
  * @param {Response} res
692
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
727
+ * @param {{
728
+ * onPayload?: (p: unknown) => Promise<void> | void,
729
+ * onRawRead?: (meta: RawReadMeta) => void,
730
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
731
+ * }} opts
732
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
733
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
734
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
693
735
  * @returns {Promise<void>}
694
736
  */
695
737
  async _consumeSseStream(res, opts) {
696
- const { onPayload } = opts;
738
+ const { onPayload, onRawRead, responseMeta } = opts;
697
739
  const reader = res.body.getReader();
698
740
  const decoder = new TextDecoder();
699
741
  let buffer = "";
700
742
  let thrown;
743
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
744
+ let rawReadFired = false;
745
+ const emitRawRead = (done, value) => {
746
+ if (!onRawRead) return;
747
+ try {
748
+ let textPreview = "";
749
+ if (value && value.byteLength) {
750
+ textPreview = previewDecoder.decode(value).slice(0, 120);
751
+ }
752
+ const meta = {
753
+ ts: Date.now(),
754
+ byteLength: value && value.byteLength ? value.byteLength : 0,
755
+ done: !!done,
756
+ textPreview
757
+ };
758
+ if (!rawReadFired) {
759
+ meta.status = responseMeta ? responseMeta.status : void 0;
760
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : void 0;
761
+ meta.contentType = responseMeta ? responseMeta.contentType : void 0;
762
+ }
763
+ rawReadFired = true;
764
+ onRawRead(meta);
765
+ } catch {
766
+ }
767
+ };
701
768
  const processFrame = async (part) => {
702
769
  if (!part.trim()) return null;
703
770
  let eventName = "message";
@@ -739,6 +806,7 @@ ${piece}` : piece;
739
806
  try {
740
807
  while (true) {
741
808
  const { done, value } = await reader.read();
809
+ emitRawRead(done, value);
742
810
  if (done) {
743
811
  buffer += decoder.decode();
744
812
  const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
package/dist/index.d.cts CHANGED
@@ -195,6 +195,40 @@ const TEXT_ENCODER = new TextEncoder();
195
195
  * `deliver()` don't silently drop the header.
196
196
  * @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass
197
197
  * `'/continue'` for tool-result resume on amsg-instant 0.9.0+.
198
+ * @property {(meta: RawReadMeta) => void} [onRawRead] - Optional raw-read telemetry hook for the
199
+ * foreground SSE transport. Fires once per `reader.read()` BEFORE any SSE parsing/filtering, so it
200
+ * sees every byte that reached the client — including `: keepalive` comment frames that the parser
201
+ * silently drops. Use it to tell "connection alive but no business data" apart from "no bytes flowing
202
+ * at all" when diagnosing stalled streams. Purely observational: throws are swallowed and never affect
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.
215
+ */
216
+
217
+ /**
218
+ * Metadata for a single raw `reader.read()` on the SSE body, passed to
219
+ * `DeliverOptions.onRawRead`. The response-meta fields
220
+ * (`status` / `contentEncoding` / `contentType`) are only populated on the
221
+ * first invocation; later calls omit them.
222
+ *
223
+ * @typedef {Object} RawReadMeta
224
+ * @property {number} ts - `Date.now()` at the moment the read resolved.
225
+ * @property {number} byteLength - Bytes in this chunk (`value?.byteLength ?? 0`).
226
+ * @property {boolean} done - The `done` flag from `reader.read()`.
227
+ * @property {string} textPreview - First ~120 chars of this chunk decoded as UTF-8,
228
+ * WITHOUT any keepalive/comment filtering (so `:`-prefixed lines stay visible).
229
+ * @property {string|null} [contentEncoding] - `res.headers.get('content-encoding')`. First call only.
230
+ * @property {string|null} [contentType] - `res.headers.get('content-type')`. First call only.
231
+ * @property {number} [status] - `res.status`. First call only.
198
232
  */
199
233
 
200
234
  /**
@@ -243,6 +277,68 @@ function classifyContentType(contentType) {
243
277
  return 'unknown';
244
278
  }
245
279
 
280
+ /**
281
+ * Default size floor for request-body gzip: bodies at or below this are not
282
+ * worth compressing (the gzip header/overhead can outweigh the gain on tiny
283
+ * payloads). 16 KB matches the contract documented on `DeliverOptions.compressRequest`.
284
+ */
285
+ const COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
286
+
287
+ /**
288
+ * Custom request header used to mark a gzip-compressed body. Deliberately NOT
289
+ * the standard `Content-Encoding` — CDNs / reverse proxies (Cloudflare, etc.)
290
+ * auto-decompress `Content-Encoding: gzip` on the way in, which would double-
291
+ * decompress and corrupt the body. The receiving worker keys off this custom
292
+ * header to know it must gunzip the body itself.
293
+ */
294
+ const COMPRESS_REQUEST_HEADER = 'X-Amsg-Request-Encoding';
295
+
296
+ /**
297
+ * Optionally gzip a request body string before it hits `fetch`.
298
+ *
299
+ * Pure optimization with graceful degradation: returns the original plaintext
300
+ * body (and no extra header) whenever compression is disabled, the body is at
301
+ * or below the threshold, the runtime lacks `CompressionStream`, or anything
302
+ * throws. The wire bytes shrink (Chinese / repetitive JSON compresses ~5-8x)
303
+ * so large uploads finish before flaky links time out — without dropping any
304
+ * context. Decompression is the receiving worker's job (keyed off
305
+ * `X-Amsg-Request-Encoding: gzip`).
306
+ *
307
+ * @param {string} body - The already-serialized request body (plaintext JSON).
308
+ * @param {boolean | { thresholdBytes?: number } | undefined} compressRequest
309
+ * `undefined`/falsy ⇒ disabled (no-op, backward compatible). `true` / `{}` ⇒
310
+ * enabled at the 16 KB default. `{ thresholdBytes: N }` ⇒ enabled at N bytes.
311
+ * @returns {Promise<{ body: string | Uint8Array, header: string | null }>}
312
+ * `header` is the gzip marker header name to set when compression happened,
313
+ * or `null` to send plaintext with no extra header.
314
+ */
315
+ async function maybeCompressRequestBody(body, compressRequest) {
316
+ // Disabled / no opt-in ⇒ behavior unchanged.
317
+ if (!compressRequest) return { body, header: null };
318
+
319
+ const threshold =
320
+ typeof compressRequest === 'object' && typeof compressRequest.thresholdBytes === 'number'
321
+ ? compressRequest.thresholdBytes
322
+ : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
323
+
324
+ try {
325
+ if (typeof CompressionStream === 'undefined') return { body, header: null };
326
+
327
+ const bytes = new TextEncoder().encode(body);
328
+ if (bytes.length <= threshold) return { body, header: null };
329
+
330
+ const gz = new Uint8Array(
331
+ await new Response(
332
+ new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
333
+ ).arrayBuffer()
334
+ );
335
+ return { body: gz, header: COMPRESS_REQUEST_HEADER };
336
+ } catch {
337
+ // Compression is an optimization, never a failure mode: fall back to plaintext.
338
+ return { body, header: null };
339
+ }
340
+ }
341
+
246
342
  class ReiClient {
247
343
  /**
248
344
  * @param {ReiClientConfig} config
@@ -536,7 +632,8 @@ class ReiClient {
536
632
  }
537
633
  const {
538
634
  delivery, timeoutMs, onChunk, postTransportGraceMs,
539
- signal, headers, authorization, endpointPath,
635
+ signal, headers, authorization, endpointPath, onRawRead,
636
+ compressRequest,
540
637
  } = opts;
541
638
 
542
639
  if (!delivery || typeof delivery !== 'object') {
@@ -629,6 +726,8 @@ class ReiClient {
629
726
  const result = await this._runInstantTransport(built, {
630
727
  signal: internalAbort.signal,
631
728
  onChunk: wrappedOnChunk,
729
+ onRawRead,
730
+ compressRequest,
632
731
  });
633
732
  if (finalized) return;
634
733
  transportEnded = true;
@@ -981,14 +1080,23 @@ class ReiClient {
981
1080
  *
982
1081
  * @private
983
1082
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
984
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
1083
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
1084
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
1085
+ * `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
985
1086
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
986
1087
  */
987
1088
  async _runInstantTransport(built, opts) {
988
- const { signal, onChunk } = opts;
1089
+ const { signal, onChunk, onRawRead, compressRequest } = opts;
989
1090
  const { url, headers, body } = built;
990
1091
 
991
- const res = await fetch(url, { method: 'POST', headers, body, signal });
1092
+ // Optionally gzip the request body (opt-in, graceful fallback to plaintext).
1093
+ const { body: wireBody, header: compressionHeader } =
1094
+ await maybeCompressRequestBody(body, compressRequest);
1095
+ const wireHeaders = compressionHeader
1096
+ ? { ...headers, [compressionHeader]: 'gzip' }
1097
+ : headers;
1098
+
1099
+ const res = await fetch(url, { method: 'POST', headers: wireHeaders, body: wireBody, signal });
992
1100
 
993
1101
  if (!res.ok) {
994
1102
  const text = await res.text().catch(() => '');
@@ -1001,7 +1109,15 @@ class ReiClient {
1001
1109
  const kind = classifyContentType(contentType);
1002
1110
  if (kind === 'sse') {
1003
1111
  if (!res.body) throw new Error('Response body is null');
1004
- await this._consumeSseStream(res, { onPayload: onChunk });
1112
+ await this._consumeSseStream(res, {
1113
+ onPayload: onChunk,
1114
+ onRawRead,
1115
+ responseMeta: {
1116
+ status: res.status,
1117
+ contentEncoding: res.headers.get('content-encoding'),
1118
+ contentType: res.headers.get('content-type'),
1119
+ },
1120
+ });
1005
1121
  return { kind: 'sse' };
1006
1122
  }
1007
1123
  if (kind === 'json') {
@@ -1019,16 +1135,54 @@ class ReiClient {
1019
1135
  *
1020
1136
  * @private
1021
1137
  * @param {Response} res
1022
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1138
+ * @param {{
1139
+ * onPayload?: (p: unknown) => Promise<void> | void,
1140
+ * onRawRead?: (meta: RawReadMeta) => void,
1141
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1142
+ * }} opts
1143
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1144
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1145
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
1023
1146
  * @returns {Promise<void>}
1024
1147
  */
1025
1148
  async _consumeSseStream(res, opts) {
1026
- const { onPayload } = opts;
1149
+ const { onPayload, onRawRead, responseMeta } = opts;
1027
1150
  const reader = res.body.getReader();
1028
1151
  const decoder = new TextDecoder();
1029
1152
  let buffer = '';
1030
1153
  let thrown;
1031
1154
 
1155
+ // Raw read-loop telemetry (opt-in via onRawRead). Kept completely
1156
+ // separate from the parsing path: a one-shot decoder for the preview so
1157
+ // it never perturbs the streaming `decoder` above, and the first call
1158
+ // carries response meta (status / encoding / content-type).
1159
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
1160
+ let rawReadFired = false;
1161
+ const emitRawRead = (done, value) => {
1162
+ if (!onRawRead) return;
1163
+ try {
1164
+ let textPreview = '';
1165
+ if (value && value.byteLength) {
1166
+ // One-shot decode (no { stream: true }) so we don't carry state
1167
+ // between calls and disturb the main buffer's decoder.
1168
+ textPreview = previewDecoder.decode(value).slice(0, 120);
1169
+ }
1170
+ const meta = {
1171
+ ts: Date.now(),
1172
+ byteLength: value && value.byteLength ? value.byteLength : 0,
1173
+ done: !!done,
1174
+ textPreview,
1175
+ };
1176
+ if (!rawReadFired) {
1177
+ meta.status = responseMeta ? responseMeta.status : undefined;
1178
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : undefined;
1179
+ meta.contentType = responseMeta ? responseMeta.contentType : undefined;
1180
+ }
1181
+ rawReadFired = true;
1182
+ onRawRead(meta);
1183
+ } catch { /* telemetry must never break the transport */ }
1184
+ };
1185
+
1032
1186
  // Parse one SSE frame body (lines between two terminators). Returns
1033
1187
  // `'done'` if the frame signals end-of-stream so the caller can
1034
1188
  // unwind without consuming further frames. Throws on `event: error`.
@@ -1071,6 +1225,7 @@ class ReiClient {
1071
1225
  try {
1072
1226
  while (true) {
1073
1227
  const { done, value } = await reader.read();
1228
+ emitRawRead(done, value);
1074
1229
  if (done) {
1075
1230
  // Flush any tail bytes the decoder held back (partial UTF-8
1076
1231
  // sequences split across the final chunk boundary).
package/dist/index.d.ts CHANGED
@@ -195,6 +195,40 @@ const TEXT_ENCODER = new TextEncoder();
195
195
  * `deliver()` don't silently drop the header.
196
196
  * @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass
197
197
  * `'/continue'` for tool-result resume on amsg-instant 0.9.0+.
198
+ * @property {(meta: RawReadMeta) => void} [onRawRead] - Optional raw-read telemetry hook for the
199
+ * foreground SSE transport. Fires once per `reader.read()` BEFORE any SSE parsing/filtering, so it
200
+ * sees every byte that reached the client — including `: keepalive` comment frames that the parser
201
+ * silently drops. Use it to tell "connection alive but no business data" apart from "no bytes flowing
202
+ * at all" when diagnosing stalled streams. Purely observational: throws are swallowed and never affect
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.
215
+ */
216
+
217
+ /**
218
+ * Metadata for a single raw `reader.read()` on the SSE body, passed to
219
+ * `DeliverOptions.onRawRead`. The response-meta fields
220
+ * (`status` / `contentEncoding` / `contentType`) are only populated on the
221
+ * first invocation; later calls omit them.
222
+ *
223
+ * @typedef {Object} RawReadMeta
224
+ * @property {number} ts - `Date.now()` at the moment the read resolved.
225
+ * @property {number} byteLength - Bytes in this chunk (`value?.byteLength ?? 0`).
226
+ * @property {boolean} done - The `done` flag from `reader.read()`.
227
+ * @property {string} textPreview - First ~120 chars of this chunk decoded as UTF-8,
228
+ * WITHOUT any keepalive/comment filtering (so `:`-prefixed lines stay visible).
229
+ * @property {string|null} [contentEncoding] - `res.headers.get('content-encoding')`. First call only.
230
+ * @property {string|null} [contentType] - `res.headers.get('content-type')`. First call only.
231
+ * @property {number} [status] - `res.status`. First call only.
198
232
  */
199
233
 
200
234
  /**
@@ -243,6 +277,68 @@ function classifyContentType(contentType) {
243
277
  return 'unknown';
244
278
  }
245
279
 
280
+ /**
281
+ * Default size floor for request-body gzip: bodies at or below this are not
282
+ * worth compressing (the gzip header/overhead can outweigh the gain on tiny
283
+ * payloads). 16 KB matches the contract documented on `DeliverOptions.compressRequest`.
284
+ */
285
+ const COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
286
+
287
+ /**
288
+ * Custom request header used to mark a gzip-compressed body. Deliberately NOT
289
+ * the standard `Content-Encoding` — CDNs / reverse proxies (Cloudflare, etc.)
290
+ * auto-decompress `Content-Encoding: gzip` on the way in, which would double-
291
+ * decompress and corrupt the body. The receiving worker keys off this custom
292
+ * header to know it must gunzip the body itself.
293
+ */
294
+ const COMPRESS_REQUEST_HEADER = 'X-Amsg-Request-Encoding';
295
+
296
+ /**
297
+ * Optionally gzip a request body string before it hits `fetch`.
298
+ *
299
+ * Pure optimization with graceful degradation: returns the original plaintext
300
+ * body (and no extra header) whenever compression is disabled, the body is at
301
+ * or below the threshold, the runtime lacks `CompressionStream`, or anything
302
+ * throws. The wire bytes shrink (Chinese / repetitive JSON compresses ~5-8x)
303
+ * so large uploads finish before flaky links time out — without dropping any
304
+ * context. Decompression is the receiving worker's job (keyed off
305
+ * `X-Amsg-Request-Encoding: gzip`).
306
+ *
307
+ * @param {string} body - The already-serialized request body (plaintext JSON).
308
+ * @param {boolean | { thresholdBytes?: number } | undefined} compressRequest
309
+ * `undefined`/falsy ⇒ disabled (no-op, backward compatible). `true` / `{}` ⇒
310
+ * enabled at the 16 KB default. `{ thresholdBytes: N }` ⇒ enabled at N bytes.
311
+ * @returns {Promise<{ body: string | Uint8Array, header: string | null }>}
312
+ * `header` is the gzip marker header name to set when compression happened,
313
+ * or `null` to send plaintext with no extra header.
314
+ */
315
+ async function maybeCompressRequestBody(body, compressRequest) {
316
+ // Disabled / no opt-in ⇒ behavior unchanged.
317
+ if (!compressRequest) return { body, header: null };
318
+
319
+ const threshold =
320
+ typeof compressRequest === 'object' && typeof compressRequest.thresholdBytes === 'number'
321
+ ? compressRequest.thresholdBytes
322
+ : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
323
+
324
+ try {
325
+ if (typeof CompressionStream === 'undefined') return { body, header: null };
326
+
327
+ const bytes = new TextEncoder().encode(body);
328
+ if (bytes.length <= threshold) return { body, header: null };
329
+
330
+ const gz = new Uint8Array(
331
+ await new Response(
332
+ new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))
333
+ ).arrayBuffer()
334
+ );
335
+ return { body: gz, header: COMPRESS_REQUEST_HEADER };
336
+ } catch {
337
+ // Compression is an optimization, never a failure mode: fall back to plaintext.
338
+ return { body, header: null };
339
+ }
340
+ }
341
+
246
342
  class ReiClient {
247
343
  /**
248
344
  * @param {ReiClientConfig} config
@@ -536,7 +632,8 @@ class ReiClient {
536
632
  }
537
633
  const {
538
634
  delivery, timeoutMs, onChunk, postTransportGraceMs,
539
- signal, headers, authorization, endpointPath,
635
+ signal, headers, authorization, endpointPath, onRawRead,
636
+ compressRequest,
540
637
  } = opts;
541
638
 
542
639
  if (!delivery || typeof delivery !== 'object') {
@@ -629,6 +726,8 @@ class ReiClient {
629
726
  const result = await this._runInstantTransport(built, {
630
727
  signal: internalAbort.signal,
631
728
  onChunk: wrappedOnChunk,
729
+ onRawRead,
730
+ compressRequest,
632
731
  });
633
732
  if (finalized) return;
634
733
  transportEnded = true;
@@ -981,14 +1080,23 @@ class ReiClient {
981
1080
  *
982
1081
  * @private
983
1082
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
984
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
1083
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
1084
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
1085
+ * `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
985
1086
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
986
1087
  */
987
1088
  async _runInstantTransport(built, opts) {
988
- const { signal, onChunk } = opts;
1089
+ const { signal, onChunk, onRawRead, compressRequest } = opts;
989
1090
  const { url, headers, body } = built;
990
1091
 
991
- const res = await fetch(url, { method: 'POST', headers, body, signal });
1092
+ // Optionally gzip the request body (opt-in, graceful fallback to plaintext).
1093
+ const { body: wireBody, header: compressionHeader } =
1094
+ await maybeCompressRequestBody(body, compressRequest);
1095
+ const wireHeaders = compressionHeader
1096
+ ? { ...headers, [compressionHeader]: 'gzip' }
1097
+ : headers;
1098
+
1099
+ const res = await fetch(url, { method: 'POST', headers: wireHeaders, body: wireBody, signal });
992
1100
 
993
1101
  if (!res.ok) {
994
1102
  const text = await res.text().catch(() => '');
@@ -1001,7 +1109,15 @@ class ReiClient {
1001
1109
  const kind = classifyContentType(contentType);
1002
1110
  if (kind === 'sse') {
1003
1111
  if (!res.body) throw new Error('Response body is null');
1004
- await this._consumeSseStream(res, { onPayload: onChunk });
1112
+ await this._consumeSseStream(res, {
1113
+ onPayload: onChunk,
1114
+ onRawRead,
1115
+ responseMeta: {
1116
+ status: res.status,
1117
+ contentEncoding: res.headers.get('content-encoding'),
1118
+ contentType: res.headers.get('content-type'),
1119
+ },
1120
+ });
1005
1121
  return { kind: 'sse' };
1006
1122
  }
1007
1123
  if (kind === 'json') {
@@ -1019,16 +1135,54 @@ class ReiClient {
1019
1135
  *
1020
1136
  * @private
1021
1137
  * @param {Response} res
1022
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1138
+ * @param {{
1139
+ * onPayload?: (p: unknown) => Promise<void> | void,
1140
+ * onRawRead?: (meta: RawReadMeta) => void,
1141
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1142
+ * }} opts
1143
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1144
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1145
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
1023
1146
  * @returns {Promise<void>}
1024
1147
  */
1025
1148
  async _consumeSseStream(res, opts) {
1026
- const { onPayload } = opts;
1149
+ const { onPayload, onRawRead, responseMeta } = opts;
1027
1150
  const reader = res.body.getReader();
1028
1151
  const decoder = new TextDecoder();
1029
1152
  let buffer = '';
1030
1153
  let thrown;
1031
1154
 
1155
+ // Raw read-loop telemetry (opt-in via onRawRead). Kept completely
1156
+ // separate from the parsing path: a one-shot decoder for the preview so
1157
+ // it never perturbs the streaming `decoder` above, and the first call
1158
+ // carries response meta (status / encoding / content-type).
1159
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
1160
+ let rawReadFired = false;
1161
+ const emitRawRead = (done, value) => {
1162
+ if (!onRawRead) return;
1163
+ try {
1164
+ let textPreview = '';
1165
+ if (value && value.byteLength) {
1166
+ // One-shot decode (no { stream: true }) so we don't carry state
1167
+ // between calls and disturb the main buffer's decoder.
1168
+ textPreview = previewDecoder.decode(value).slice(0, 120);
1169
+ }
1170
+ const meta = {
1171
+ ts: Date.now(),
1172
+ byteLength: value && value.byteLength ? value.byteLength : 0,
1173
+ done: !!done,
1174
+ textPreview,
1175
+ };
1176
+ if (!rawReadFired) {
1177
+ meta.status = responseMeta ? responseMeta.status : undefined;
1178
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : undefined;
1179
+ meta.contentType = responseMeta ? responseMeta.contentType : undefined;
1180
+ }
1181
+ rawReadFired = true;
1182
+ onRawRead(meta);
1183
+ } catch { /* telemetry must never break the transport */ }
1184
+ };
1185
+
1032
1186
  // Parse one SSE frame body (lines between two terminators). Returns
1033
1187
  // `'done'` if the frame signals end-of-stream so the caller can
1034
1188
  // unwind without consuming further frames. Throws on `event: error`.
@@ -1071,6 +1225,7 @@ class ReiClient {
1071
1225
  try {
1072
1226
  while (true) {
1073
1227
  const { done, value } = await reader.read();
1228
+ emitRawRead(done, value);
1074
1229
  if (done) {
1075
1230
  // Flush any tail bytes the decoder held back (partial UTF-8
1076
1231
  // sequences split across the final chunk boundary).
package/dist/index.mjs CHANGED
@@ -32,6 +32,25 @@ function classifyContentType(contentType) {
32
32
  if (/^application\/[\w.+-]+\+json$/.test(main)) return "json";
33
33
  return "unknown";
34
34
  }
35
+ var COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384;
36
+ var COMPRESS_REQUEST_HEADER = "X-Amsg-Request-Encoding";
37
+ async function maybeCompressRequestBody(body, compressRequest) {
38
+ if (!compressRequest) return { body, header: null };
39
+ const threshold = typeof compressRequest === "object" && typeof compressRequest.thresholdBytes === "number" ? compressRequest.thresholdBytes : COMPRESS_REQUEST_DEFAULT_THRESHOLD;
40
+ try {
41
+ if (typeof CompressionStream === "undefined") return { body, header: null };
42
+ const bytes = new TextEncoder().encode(body);
43
+ if (bytes.length <= threshold) return { body, header: null };
44
+ const gz = new Uint8Array(
45
+ await new Response(
46
+ new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))
47
+ ).arrayBuffer()
48
+ );
49
+ return { body: gz, header: COMPRESS_REQUEST_HEADER };
50
+ } catch {
51
+ return { body, header: null };
52
+ }
53
+ }
35
54
  var ReiClient = class {
36
55
  /**
37
56
  * @param {ReiClientConfig} config
@@ -293,7 +312,9 @@ var ReiClient = class {
293
312
  signal,
294
313
  headers,
295
314
  authorization,
296
- endpointPath
315
+ endpointPath,
316
+ onRawRead,
317
+ compressRequest
297
318
  } = opts;
298
319
  if (!delivery || typeof delivery !== "object") {
299
320
  throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
@@ -354,7 +375,9 @@ var ReiClient = class {
354
375
  try {
355
376
  const result = await this._runInstantTransport(built, {
356
377
  signal: internalAbort.signal,
357
- onChunk: wrappedOnChunk
378
+ onChunk: wrappedOnChunk,
379
+ onRawRead,
380
+ compressRequest
358
381
  });
359
382
  if (finalized) return;
360
383
  transportEnded = true;
@@ -633,13 +656,17 @@ var ReiClient = class {
633
656
  *
634
657
  * @private
635
658
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
636
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
659
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts
660
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
661
+ * `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`).
637
662
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
638
663
  */
639
664
  async _runInstantTransport(built, opts) {
640
- const { signal, onChunk } = opts;
665
+ const { signal, onChunk, onRawRead, compressRequest } = opts;
641
666
  const { url, headers, body } = built;
642
- const res = await fetch(url, { method: "POST", headers, body, signal });
667
+ const { body: wireBody, header: compressionHeader } = await maybeCompressRequestBody(body, compressRequest);
668
+ const wireHeaders = compressionHeader ? { ...headers, [compressionHeader]: "gzip" } : headers;
669
+ const res = await fetch(url, { method: "POST", headers: wireHeaders, body: wireBody, signal });
643
670
  if (!res.ok) {
644
671
  const text2 = await res.text().catch(() => "");
645
672
  const err = new Error(`Instant request failed: ${res.status} ${text2}`);
@@ -650,7 +677,15 @@ var ReiClient = class {
650
677
  const kind = classifyContentType(contentType);
651
678
  if (kind === "sse") {
652
679
  if (!res.body) throw new Error("Response body is null");
653
- await this._consumeSseStream(res, { onPayload: onChunk });
680
+ await this._consumeSseStream(res, {
681
+ onPayload: onChunk,
682
+ onRawRead,
683
+ responseMeta: {
684
+ status: res.status,
685
+ contentEncoding: res.headers.get("content-encoding"),
686
+ contentType: res.headers.get("content-type")
687
+ }
688
+ });
654
689
  return { kind: "sse" };
655
690
  }
656
691
  if (kind === "json") {
@@ -667,15 +702,47 @@ var ReiClient = class {
667
702
  *
668
703
  * @private
669
704
  * @param {Response} res
670
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
705
+ * @param {{
706
+ * onPayload?: (p: unknown) => Promise<void> | void,
707
+ * onRawRead?: (meta: RawReadMeta) => void,
708
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
709
+ * }} opts
710
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
711
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
712
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
671
713
  * @returns {Promise<void>}
672
714
  */
673
715
  async _consumeSseStream(res, opts) {
674
- const { onPayload } = opts;
716
+ const { onPayload, onRawRead, responseMeta } = opts;
675
717
  const reader = res.body.getReader();
676
718
  const decoder = new TextDecoder();
677
719
  let buffer = "";
678
720
  let thrown;
721
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
722
+ let rawReadFired = false;
723
+ const emitRawRead = (done, value) => {
724
+ if (!onRawRead) return;
725
+ try {
726
+ let textPreview = "";
727
+ if (value && value.byteLength) {
728
+ textPreview = previewDecoder.decode(value).slice(0, 120);
729
+ }
730
+ const meta = {
731
+ ts: Date.now(),
732
+ byteLength: value && value.byteLength ? value.byteLength : 0,
733
+ done: !!done,
734
+ textPreview
735
+ };
736
+ if (!rawReadFired) {
737
+ meta.status = responseMeta ? responseMeta.status : void 0;
738
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : void 0;
739
+ meta.contentType = responseMeta ? responseMeta.contentType : void 0;
740
+ }
741
+ rawReadFired = true;
742
+ onRawRead(meta);
743
+ } catch {
744
+ }
745
+ };
679
746
  const processFrame = async (part) => {
680
747
  if (!part.trim()) return null;
681
748
  let eventName = "message";
@@ -717,6 +784,7 @@ ${piece}` : piece;
717
784
  try {
718
785
  while (true) {
719
786
  const { done, value } = await reader.read();
787
+ emitRawRead(done, value);
720
788
  if (done) {
721
789
  buffer += decoder.decode();
722
790
  const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-client",
3
- "version": "2.5.0",
3
+ "version": "2.7.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",