@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 +14 -0
- package/dist/index.cjs +51 -7
- package/dist/index.d.cts +78 -6
- package/dist/index.d.ts +78 -6
- package/dist/index.mjs +51 -7
- package/package.json +1 -1
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, {
|
|
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 {{
|
|
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, {
|
|
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 {{
|
|
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, {
|
|
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 {{
|
|
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, {
|
|
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 {{
|
|
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.
|
|
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",
|