@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 +23 -0
- package/dist/index.cjs +76 -8
- package/dist/index.d.cts +162 -7
- package/dist/index.d.ts +162 -7
- package/dist/index.mjs +76 -8
- 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
|
|
@@ -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
|
|
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, {
|
|
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 {{
|
|
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
|
-
|
|
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, {
|
|
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 {{
|
|
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
|
-
|
|
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, {
|
|
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 {{
|
|
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
|
|
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, {
|
|
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 {{
|
|
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.
|
|
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",
|