@rei-standard/amsg-client 2.5.0 → 2.6.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
@@ -183,8 +185,20 @@ interface ObservedDeliveryReceipt {
183
185
  sessionId?: string; // ↑
184
186
  channel?: string; // 'sw' / 'ipc' / 'native' / 'poll' / 任意诊断 label
185
187
  }
188
+
189
+ interface RawReadMeta {
190
+ ts: number; // Date.now()
191
+ byteLength: number; // 本次 reader.read() 拿到的字节数
192
+ done: boolean; // 流是否结束
193
+ textPreview: string; // 本次数据解码后的前 120 字符,保留 ':' keepalive 注释行
194
+ status?: number; // 仅首帧带:响应状态码
195
+ contentEncoding?: string | null; // 仅首帧带:响应 Content-Encoding(查是否被边缘压缩)
196
+ contentType?: string | null; // 仅首帧带
197
+ }
186
198
  ```
187
199
 
200
+ > `onRawRead` 是诊断钩子:SSE 解析层默认丢弃 `:` 注释行(含每秒一发的 keepalive),出问题时无从判断「静默期里到底有没有字节到达」。挂上它就能在 raw `reader.read()` 这一层看到每次读到的原始字节与 keepalive 帧。不传则零开销、行为不变。
201
+
188
202
  ### `delivery.mode` 必须显式选
189
203
 
190
204
  | mode | 何时用 | outcome 取值 |
package/dist/index.cjs CHANGED
@@ -315,7 +315,8 @@ var ReiClient = class {
315
315
  signal,
316
316
  headers,
317
317
  authorization,
318
- endpointPath
318
+ endpointPath,
319
+ onRawRead
319
320
  } = opts;
320
321
  if (!delivery || typeof delivery !== "object") {
321
322
  throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
@@ -376,7 +377,8 @@ var ReiClient = class {
376
377
  try {
377
378
  const result = await this._runInstantTransport(built, {
378
379
  signal: internalAbort.signal,
379
- onChunk: wrappedOnChunk
380
+ onChunk: wrappedOnChunk,
381
+ onRawRead
380
382
  });
381
383
  if (finalized) return;
382
384
  transportEnded = true;
@@ -655,11 +657,12 @@ var ReiClient = class {
655
657
  *
656
658
  * @private
657
659
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
658
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
660
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
661
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
659
662
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
660
663
  */
661
664
  async _runInstantTransport(built, opts) {
662
- const { signal, onChunk } = opts;
665
+ const { signal, onChunk, onRawRead } = opts;
663
666
  const { url, headers, body } = built;
664
667
  const res = await fetch(url, { method: "POST", headers, body, signal });
665
668
  if (!res.ok) {
@@ -672,7 +675,15 @@ var ReiClient = class {
672
675
  const kind = classifyContentType(contentType);
673
676
  if (kind === "sse") {
674
677
  if (!res.body) throw new Error("Response body is null");
675
- await this._consumeSseStream(res, { onPayload: onChunk });
678
+ await this._consumeSseStream(res, {
679
+ onPayload: onChunk,
680
+ onRawRead,
681
+ responseMeta: {
682
+ status: res.status,
683
+ contentEncoding: res.headers.get("content-encoding"),
684
+ contentType: res.headers.get("content-type")
685
+ }
686
+ });
676
687
  return { kind: "sse" };
677
688
  }
678
689
  if (kind === "json") {
@@ -689,15 +700,47 @@ var ReiClient = class {
689
700
  *
690
701
  * @private
691
702
  * @param {Response} res
692
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
703
+ * @param {{
704
+ * onPayload?: (p: unknown) => Promise<void> | void,
705
+ * onRawRead?: (meta: RawReadMeta) => void,
706
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
707
+ * }} opts
708
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
709
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
710
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
693
711
  * @returns {Promise<void>}
694
712
  */
695
713
  async _consumeSseStream(res, opts) {
696
- const { onPayload } = opts;
714
+ const { onPayload, onRawRead, responseMeta } = opts;
697
715
  const reader = res.body.getReader();
698
716
  const decoder = new TextDecoder();
699
717
  let buffer = "";
700
718
  let thrown;
719
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
720
+ let rawReadFired = false;
721
+ const emitRawRead = (done, value) => {
722
+ if (!onRawRead) return;
723
+ try {
724
+ let textPreview = "";
725
+ if (value && value.byteLength) {
726
+ textPreview = previewDecoder.decode(value).slice(0, 120);
727
+ }
728
+ const meta = {
729
+ ts: Date.now(),
730
+ byteLength: value && value.byteLength ? value.byteLength : 0,
731
+ done: !!done,
732
+ textPreview
733
+ };
734
+ if (!rawReadFired) {
735
+ meta.status = responseMeta ? responseMeta.status : void 0;
736
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : void 0;
737
+ meta.contentType = responseMeta ? responseMeta.contentType : void 0;
738
+ }
739
+ rawReadFired = true;
740
+ onRawRead(meta);
741
+ } catch {
742
+ }
743
+ };
701
744
  const processFrame = async (part) => {
702
745
  if (!part.trim()) return null;
703
746
  let eventName = "message";
@@ -739,6 +782,7 @@ ${piece}` : piece;
739
782
  try {
740
783
  while (true) {
741
784
  const { done, value } = await reader.read();
785
+ emitRawRead(done, value);
742
786
  if (done) {
743
787
  buffer += decoder.decode();
744
788
  const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
package/dist/index.d.cts CHANGED
@@ -195,6 +195,29 @@ 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
+ */
205
+
206
+ /**
207
+ * Metadata for a single raw `reader.read()` on the SSE body, passed to
208
+ * `DeliverOptions.onRawRead`. The response-meta fields
209
+ * (`status` / `contentEncoding` / `contentType`) are only populated on the
210
+ * first invocation; later calls omit them.
211
+ *
212
+ * @typedef {Object} RawReadMeta
213
+ * @property {number} ts - `Date.now()` at the moment the read resolved.
214
+ * @property {number} byteLength - Bytes in this chunk (`value?.byteLength ?? 0`).
215
+ * @property {boolean} done - The `done` flag from `reader.read()`.
216
+ * @property {string} textPreview - First ~120 chars of this chunk decoded as UTF-8,
217
+ * WITHOUT any keepalive/comment filtering (so `:`-prefixed lines stay visible).
218
+ * @property {string|null} [contentEncoding] - `res.headers.get('content-encoding')`. First call only.
219
+ * @property {string|null} [contentType] - `res.headers.get('content-type')`. First call only.
220
+ * @property {number} [status] - `res.status`. First call only.
198
221
  */
199
222
 
200
223
  /**
@@ -536,7 +559,7 @@ class ReiClient {
536
559
  }
537
560
  const {
538
561
  delivery, timeoutMs, onChunk, postTransportGraceMs,
539
- signal, headers, authorization, endpointPath,
562
+ signal, headers, authorization, endpointPath, onRawRead,
540
563
  } = opts;
541
564
 
542
565
  if (!delivery || typeof delivery !== 'object') {
@@ -629,6 +652,7 @@ class ReiClient {
629
652
  const result = await this._runInstantTransport(built, {
630
653
  signal: internalAbort.signal,
631
654
  onChunk: wrappedOnChunk,
655
+ onRawRead,
632
656
  });
633
657
  if (finalized) return;
634
658
  transportEnded = true;
@@ -981,11 +1005,12 @@ class ReiClient {
981
1005
  *
982
1006
  * @private
983
1007
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
984
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
1008
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
1009
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
985
1010
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
986
1011
  */
987
1012
  async _runInstantTransport(built, opts) {
988
- const { signal, onChunk } = opts;
1013
+ const { signal, onChunk, onRawRead } = opts;
989
1014
  const { url, headers, body } = built;
990
1015
 
991
1016
  const res = await fetch(url, { method: 'POST', headers, body, signal });
@@ -1001,7 +1026,15 @@ class ReiClient {
1001
1026
  const kind = classifyContentType(contentType);
1002
1027
  if (kind === 'sse') {
1003
1028
  if (!res.body) throw new Error('Response body is null');
1004
- await this._consumeSseStream(res, { onPayload: onChunk });
1029
+ await this._consumeSseStream(res, {
1030
+ onPayload: onChunk,
1031
+ onRawRead,
1032
+ responseMeta: {
1033
+ status: res.status,
1034
+ contentEncoding: res.headers.get('content-encoding'),
1035
+ contentType: res.headers.get('content-type'),
1036
+ },
1037
+ });
1005
1038
  return { kind: 'sse' };
1006
1039
  }
1007
1040
  if (kind === 'json') {
@@ -1019,16 +1052,54 @@ class ReiClient {
1019
1052
  *
1020
1053
  * @private
1021
1054
  * @param {Response} res
1022
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1055
+ * @param {{
1056
+ * onPayload?: (p: unknown) => Promise<void> | void,
1057
+ * onRawRead?: (meta: RawReadMeta) => void,
1058
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1059
+ * }} opts
1060
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1061
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1062
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
1023
1063
  * @returns {Promise<void>}
1024
1064
  */
1025
1065
  async _consumeSseStream(res, opts) {
1026
- const { onPayload } = opts;
1066
+ const { onPayload, onRawRead, responseMeta } = opts;
1027
1067
  const reader = res.body.getReader();
1028
1068
  const decoder = new TextDecoder();
1029
1069
  let buffer = '';
1030
1070
  let thrown;
1031
1071
 
1072
+ // Raw read-loop telemetry (opt-in via onRawRead). Kept completely
1073
+ // separate from the parsing path: a one-shot decoder for the preview so
1074
+ // it never perturbs the streaming `decoder` above, and the first call
1075
+ // carries response meta (status / encoding / content-type).
1076
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
1077
+ let rawReadFired = false;
1078
+ const emitRawRead = (done, value) => {
1079
+ if (!onRawRead) return;
1080
+ try {
1081
+ let textPreview = '';
1082
+ if (value && value.byteLength) {
1083
+ // One-shot decode (no { stream: true }) so we don't carry state
1084
+ // between calls and disturb the main buffer's decoder.
1085
+ textPreview = previewDecoder.decode(value).slice(0, 120);
1086
+ }
1087
+ const meta = {
1088
+ ts: Date.now(),
1089
+ byteLength: value && value.byteLength ? value.byteLength : 0,
1090
+ done: !!done,
1091
+ textPreview,
1092
+ };
1093
+ if (!rawReadFired) {
1094
+ meta.status = responseMeta ? responseMeta.status : undefined;
1095
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : undefined;
1096
+ meta.contentType = responseMeta ? responseMeta.contentType : undefined;
1097
+ }
1098
+ rawReadFired = true;
1099
+ onRawRead(meta);
1100
+ } catch { /* telemetry must never break the transport */ }
1101
+ };
1102
+
1032
1103
  // Parse one SSE frame body (lines between two terminators). Returns
1033
1104
  // `'done'` if the frame signals end-of-stream so the caller can
1034
1105
  // unwind without consuming further frames. Throws on `event: error`.
@@ -1071,6 +1142,7 @@ class ReiClient {
1071
1142
  try {
1072
1143
  while (true) {
1073
1144
  const { done, value } = await reader.read();
1145
+ emitRawRead(done, value);
1074
1146
  if (done) {
1075
1147
  // Flush any tail bytes the decoder held back (partial UTF-8
1076
1148
  // sequences split across the final chunk boundary).
package/dist/index.d.ts CHANGED
@@ -195,6 +195,29 @@ 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
+ */
205
+
206
+ /**
207
+ * Metadata for a single raw `reader.read()` on the SSE body, passed to
208
+ * `DeliverOptions.onRawRead`. The response-meta fields
209
+ * (`status` / `contentEncoding` / `contentType`) are only populated on the
210
+ * first invocation; later calls omit them.
211
+ *
212
+ * @typedef {Object} RawReadMeta
213
+ * @property {number} ts - `Date.now()` at the moment the read resolved.
214
+ * @property {number} byteLength - Bytes in this chunk (`value?.byteLength ?? 0`).
215
+ * @property {boolean} done - The `done` flag from `reader.read()`.
216
+ * @property {string} textPreview - First ~120 chars of this chunk decoded as UTF-8,
217
+ * WITHOUT any keepalive/comment filtering (so `:`-prefixed lines stay visible).
218
+ * @property {string|null} [contentEncoding] - `res.headers.get('content-encoding')`. First call only.
219
+ * @property {string|null} [contentType] - `res.headers.get('content-type')`. First call only.
220
+ * @property {number} [status] - `res.status`. First call only.
198
221
  */
199
222
 
200
223
  /**
@@ -536,7 +559,7 @@ class ReiClient {
536
559
  }
537
560
  const {
538
561
  delivery, timeoutMs, onChunk, postTransportGraceMs,
539
- signal, headers, authorization, endpointPath,
562
+ signal, headers, authorization, endpointPath, onRawRead,
540
563
  } = opts;
541
564
 
542
565
  if (!delivery || typeof delivery !== 'object') {
@@ -629,6 +652,7 @@ class ReiClient {
629
652
  const result = await this._runInstantTransport(built, {
630
653
  signal: internalAbort.signal,
631
654
  onChunk: wrappedOnChunk,
655
+ onRawRead,
632
656
  });
633
657
  if (finalized) return;
634
658
  transportEnded = true;
@@ -981,11 +1005,12 @@ class ReiClient {
981
1005
  *
982
1006
  * @private
983
1007
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
984
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
1008
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
1009
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
985
1010
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
986
1011
  */
987
1012
  async _runInstantTransport(built, opts) {
988
- const { signal, onChunk } = opts;
1013
+ const { signal, onChunk, onRawRead } = opts;
989
1014
  const { url, headers, body } = built;
990
1015
 
991
1016
  const res = await fetch(url, { method: 'POST', headers, body, signal });
@@ -1001,7 +1026,15 @@ class ReiClient {
1001
1026
  const kind = classifyContentType(contentType);
1002
1027
  if (kind === 'sse') {
1003
1028
  if (!res.body) throw new Error('Response body is null');
1004
- await this._consumeSseStream(res, { onPayload: onChunk });
1029
+ await this._consumeSseStream(res, {
1030
+ onPayload: onChunk,
1031
+ onRawRead,
1032
+ responseMeta: {
1033
+ status: res.status,
1034
+ contentEncoding: res.headers.get('content-encoding'),
1035
+ contentType: res.headers.get('content-type'),
1036
+ },
1037
+ });
1005
1038
  return { kind: 'sse' };
1006
1039
  }
1007
1040
  if (kind === 'json') {
@@ -1019,16 +1052,54 @@ class ReiClient {
1019
1052
  *
1020
1053
  * @private
1021
1054
  * @param {Response} res
1022
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1055
+ * @param {{
1056
+ * onPayload?: (p: unknown) => Promise<void> | void,
1057
+ * onRawRead?: (meta: RawReadMeta) => void,
1058
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1059
+ * }} opts
1060
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1061
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1062
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
1023
1063
  * @returns {Promise<void>}
1024
1064
  */
1025
1065
  async _consumeSseStream(res, opts) {
1026
- const { onPayload } = opts;
1066
+ const { onPayload, onRawRead, responseMeta } = opts;
1027
1067
  const reader = res.body.getReader();
1028
1068
  const decoder = new TextDecoder();
1029
1069
  let buffer = '';
1030
1070
  let thrown;
1031
1071
 
1072
+ // Raw read-loop telemetry (opt-in via onRawRead). Kept completely
1073
+ // separate from the parsing path: a one-shot decoder for the preview so
1074
+ // it never perturbs the streaming `decoder` above, and the first call
1075
+ // carries response meta (status / encoding / content-type).
1076
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
1077
+ let rawReadFired = false;
1078
+ const emitRawRead = (done, value) => {
1079
+ if (!onRawRead) return;
1080
+ try {
1081
+ let textPreview = '';
1082
+ if (value && value.byteLength) {
1083
+ // One-shot decode (no { stream: true }) so we don't carry state
1084
+ // between calls and disturb the main buffer's decoder.
1085
+ textPreview = previewDecoder.decode(value).slice(0, 120);
1086
+ }
1087
+ const meta = {
1088
+ ts: Date.now(),
1089
+ byteLength: value && value.byteLength ? value.byteLength : 0,
1090
+ done: !!done,
1091
+ textPreview,
1092
+ };
1093
+ if (!rawReadFired) {
1094
+ meta.status = responseMeta ? responseMeta.status : undefined;
1095
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : undefined;
1096
+ meta.contentType = responseMeta ? responseMeta.contentType : undefined;
1097
+ }
1098
+ rawReadFired = true;
1099
+ onRawRead(meta);
1100
+ } catch { /* telemetry must never break the transport */ }
1101
+ };
1102
+
1032
1103
  // Parse one SSE frame body (lines between two terminators). Returns
1033
1104
  // `'done'` if the frame signals end-of-stream so the caller can
1034
1105
  // unwind without consuming further frames. Throws on `event: error`.
@@ -1071,6 +1142,7 @@ class ReiClient {
1071
1142
  try {
1072
1143
  while (true) {
1073
1144
  const { done, value } = await reader.read();
1145
+ emitRawRead(done, value);
1074
1146
  if (done) {
1075
1147
  // Flush any tail bytes the decoder held back (partial UTF-8
1076
1148
  // sequences split across the final chunk boundary).
package/dist/index.mjs CHANGED
@@ -293,7 +293,8 @@ var ReiClient = class {
293
293
  signal,
294
294
  headers,
295
295
  authorization,
296
- endpointPath
296
+ endpointPath,
297
+ onRawRead
297
298
  } = opts;
298
299
  if (!delivery || typeof delivery !== "object") {
299
300
  throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
@@ -354,7 +355,8 @@ var ReiClient = class {
354
355
  try {
355
356
  const result = await this._runInstantTransport(built, {
356
357
  signal: internalAbort.signal,
357
- onChunk: wrappedOnChunk
358
+ onChunk: wrappedOnChunk,
359
+ onRawRead
358
360
  });
359
361
  if (finalized) return;
360
362
  transportEnded = true;
@@ -633,11 +635,12 @@ var ReiClient = class {
633
635
  *
634
636
  * @private
635
637
  * @param {{ url: string, headers: Record<string, string>, body: string }} built
636
- * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
638
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void, onRawRead?: (meta: RawReadMeta) => void }} opts
639
+ * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`).
637
640
  * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
638
641
  */
639
642
  async _runInstantTransport(built, opts) {
640
- const { signal, onChunk } = opts;
643
+ const { signal, onChunk, onRawRead } = opts;
641
644
  const { url, headers, body } = built;
642
645
  const res = await fetch(url, { method: "POST", headers, body, signal });
643
646
  if (!res.ok) {
@@ -650,7 +653,15 @@ var ReiClient = class {
650
653
  const kind = classifyContentType(contentType);
651
654
  if (kind === "sse") {
652
655
  if (!res.body) throw new Error("Response body is null");
653
- await this._consumeSseStream(res, { onPayload: onChunk });
656
+ await this._consumeSseStream(res, {
657
+ onPayload: onChunk,
658
+ onRawRead,
659
+ responseMeta: {
660
+ status: res.status,
661
+ contentEncoding: res.headers.get("content-encoding"),
662
+ contentType: res.headers.get("content-type")
663
+ }
664
+ });
654
665
  return { kind: "sse" };
655
666
  }
656
667
  if (kind === "json") {
@@ -667,15 +678,47 @@ var ReiClient = class {
667
678
  *
668
679
  * @private
669
680
  * @param {Response} res
670
- * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
681
+ * @param {{
682
+ * onPayload?: (p: unknown) => Promise<void> | void,
683
+ * onRawRead?: (meta: RawReadMeta) => void,
684
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
685
+ * }} opts
686
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
687
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
688
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
671
689
  * @returns {Promise<void>}
672
690
  */
673
691
  async _consumeSseStream(res, opts) {
674
- const { onPayload } = opts;
692
+ const { onPayload, onRawRead, responseMeta } = opts;
675
693
  const reader = res.body.getReader();
676
694
  const decoder = new TextDecoder();
677
695
  let buffer = "";
678
696
  let thrown;
697
+ const previewDecoder = onRawRead ? new TextDecoder() : null;
698
+ let rawReadFired = false;
699
+ const emitRawRead = (done, value) => {
700
+ if (!onRawRead) return;
701
+ try {
702
+ let textPreview = "";
703
+ if (value && value.byteLength) {
704
+ textPreview = previewDecoder.decode(value).slice(0, 120);
705
+ }
706
+ const meta = {
707
+ ts: Date.now(),
708
+ byteLength: value && value.byteLength ? value.byteLength : 0,
709
+ done: !!done,
710
+ textPreview
711
+ };
712
+ if (!rawReadFired) {
713
+ meta.status = responseMeta ? responseMeta.status : void 0;
714
+ meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : void 0;
715
+ meta.contentType = responseMeta ? responseMeta.contentType : void 0;
716
+ }
717
+ rawReadFired = true;
718
+ onRawRead(meta);
719
+ } catch {
720
+ }
721
+ };
679
722
  const processFrame = async (part) => {
680
723
  if (!part.trim()) return null;
681
724
  let eventName = "message";
@@ -717,6 +760,7 @@ ${piece}` : piece;
717
760
  try {
718
761
  while (true) {
719
762
  const { done, value } = await reader.read();
763
+ emitRawRead(done, value);
720
764
  if (done) {
721
765
  buffer += decoder.decode();
722
766
  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.6.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",