@rei-standard/amsg-client 2.5.0-next.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 +82 -44
- package/dist/index.d.cts +105 -32
- package/dist/index.d.ts +105 -32
- package/dist/index.mjs +71 -33
- 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
|
@@ -19,21 +19,23 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
-
MESSAGE_KIND: () =>
|
|
23
|
-
MESSAGE_TYPE: () =>
|
|
24
|
-
PUSH_SOURCE: () =>
|
|
22
|
+
MESSAGE_KIND: () => import_amsg_shared2.MESSAGE_KIND,
|
|
23
|
+
MESSAGE_TYPE: () => import_amsg_shared2.MESSAGE_TYPE,
|
|
24
|
+
PUSH_SOURCE: () => import_amsg_shared2.PUSH_SOURCE,
|
|
25
25
|
ReiClient: () => ReiClient,
|
|
26
|
-
buildContentPush: () =>
|
|
27
|
-
buildErrorPush: () =>
|
|
28
|
-
buildReasoningPush: () =>
|
|
29
|
-
buildToolRequestPush: () =>
|
|
30
|
-
isContentPush: () =>
|
|
31
|
-
isErrorPush: () =>
|
|
32
|
-
isReasoningPush: () =>
|
|
33
|
-
isToolRequestPush: () =>
|
|
26
|
+
buildContentPush: () => import_amsg_shared2.buildContentPush,
|
|
27
|
+
buildErrorPush: () => import_amsg_shared2.buildErrorPush,
|
|
28
|
+
buildReasoningPush: () => import_amsg_shared2.buildReasoningPush,
|
|
29
|
+
buildToolRequestPush: () => import_amsg_shared2.buildToolRequestPush,
|
|
30
|
+
isContentPush: () => import_amsg_shared2.isContentPush,
|
|
31
|
+
isErrorPush: () => import_amsg_shared2.isErrorPush,
|
|
32
|
+
isReasoningPush: () => import_amsg_shared2.isReasoningPush,
|
|
33
|
+
isToolRequestPush: () => import_amsg_shared2.isToolRequestPush
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(src_exports);
|
|
36
36
|
var import_amsg_shared = require("@rei-standard/amsg-shared");
|
|
37
|
+
var import_amsg_shared2 = require("@rei-standard/amsg-shared");
|
|
38
|
+
var TEXT_ENCODER = new TextEncoder();
|
|
37
39
|
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
38
40
|
function makeLocalError(code, message, details) {
|
|
39
41
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
@@ -187,11 +189,11 @@ var ReiClient = class {
|
|
|
187
189
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
188
190
|
* @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
|
|
189
191
|
* - `authorization`: optional auth header to forward.
|
|
190
|
-
* - `expectsBackupPush`: opt-in
|
|
191
|
-
*
|
|
192
|
-
* "200 ≠
|
|
193
|
-
* (
|
|
194
|
-
*
|
|
192
|
+
* - `expectsBackupPush`: opt-in dev reminder. Set to `true` to log a
|
|
193
|
+
* one-shot console.warn that this is a low-level transport and
|
|
194
|
+
* "HTTP 200 ≠ delivery confirmation" once the worker has backup
|
|
195
|
+
* push enabled (amsg-instant 0.9.0+ default). Default (omitted) is
|
|
196
|
+
* silent.
|
|
195
197
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
196
198
|
*/
|
|
197
199
|
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
@@ -201,6 +203,7 @@ var ReiClient = class {
|
|
|
201
203
|
endpointPath,
|
|
202
204
|
{ authorization: opts.authorization, methodName: "sendInstant" }
|
|
203
205
|
);
|
|
206
|
+
headers["Accept"] = "application/json";
|
|
204
207
|
const res = await fetch(url, { method: "POST", headers, body });
|
|
205
208
|
return res.json();
|
|
206
209
|
}
|
|
@@ -231,10 +234,10 @@ var ReiClient = class {
|
|
|
231
234
|
* @param {(error: unknown) => void} [options.onError]
|
|
232
235
|
* @param {() => void} [options.onDone]
|
|
233
236
|
* @param {AbortSignal} [options.signal]
|
|
234
|
-
* @param {boolean} [options.expectsBackupPush] - Opt-in
|
|
235
|
-
*
|
|
236
|
-
* "
|
|
237
|
-
*
|
|
237
|
+
* @param {boolean} [options.expectsBackupPush] - Opt-in dev reminder. Set
|
|
238
|
+
* to `true` to log a one-shot console.warn that "rejection ≠ delivery
|
|
239
|
+
* failure" once the worker has backup push enabled (amsg-instant 0.9.0+
|
|
240
|
+
* default). Default (omitted) is silent.
|
|
238
241
|
* @returns {Promise<void>}
|
|
239
242
|
*/
|
|
240
243
|
async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
|
|
@@ -312,7 +315,8 @@ var ReiClient = class {
|
|
|
312
315
|
signal,
|
|
313
316
|
headers,
|
|
314
317
|
authorization,
|
|
315
|
-
endpointPath
|
|
318
|
+
endpointPath,
|
|
319
|
+
onRawRead
|
|
316
320
|
} = opts;
|
|
317
321
|
if (!delivery || typeof delivery !== "object") {
|
|
318
322
|
throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
|
|
@@ -373,7 +377,8 @@ var ReiClient = class {
|
|
|
373
377
|
try {
|
|
374
378
|
const result = await this._runInstantTransport(built, {
|
|
375
379
|
signal: internalAbort.signal,
|
|
376
|
-
onChunk: wrappedOnChunk
|
|
380
|
+
onChunk: wrappedOnChunk,
|
|
381
|
+
onRawRead
|
|
377
382
|
});
|
|
378
383
|
if (finalized) return;
|
|
379
384
|
transportEnded = true;
|
|
@@ -551,7 +556,7 @@ var ReiClient = class {
|
|
|
551
556
|
async subscribePush(vapidPublicKey, registration) {
|
|
552
557
|
const subscription = await registration.pushManager.subscribe({
|
|
553
558
|
userVisibleOnly: true,
|
|
554
|
-
applicationServerKey:
|
|
559
|
+
applicationServerKey: (0, import_amsg_shared.base64UrlToBytes)(vapidPublicKey)
|
|
555
560
|
});
|
|
556
561
|
return subscription;
|
|
557
562
|
}
|
|
@@ -599,7 +604,7 @@ var ReiClient = class {
|
|
|
599
604
|
*/
|
|
600
605
|
_assertPayloadSize(bodyJson, methodName) {
|
|
601
606
|
if (this._maxPayloadBytes == null) return;
|
|
602
|
-
const bytes =
|
|
607
|
+
const bytes = TEXT_ENCODER.encode(bodyJson).length;
|
|
603
608
|
if (bytes > this._maxPayloadBytes) {
|
|
604
609
|
throw makeLocalError(
|
|
605
610
|
"PAYLOAD_TOO_LARGE_LOCAL",
|
|
@@ -652,11 +657,12 @@ var ReiClient = class {
|
|
|
652
657
|
*
|
|
653
658
|
* @private
|
|
654
659
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
655
|
-
* @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`).
|
|
656
662
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
657
663
|
*/
|
|
658
664
|
async _runInstantTransport(built, opts) {
|
|
659
|
-
const { signal, onChunk } = opts;
|
|
665
|
+
const { signal, onChunk, onRawRead } = opts;
|
|
660
666
|
const { url, headers, body } = built;
|
|
661
667
|
const res = await fetch(url, { method: "POST", headers, body, signal });
|
|
662
668
|
if (!res.ok) {
|
|
@@ -669,7 +675,15 @@ var ReiClient = class {
|
|
|
669
675
|
const kind = classifyContentType(contentType);
|
|
670
676
|
if (kind === "sse") {
|
|
671
677
|
if (!res.body) throw new Error("Response body is null");
|
|
672
|
-
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
|
+
});
|
|
673
687
|
return { kind: "sse" };
|
|
674
688
|
}
|
|
675
689
|
if (kind === "json") {
|
|
@@ -686,15 +700,47 @@ var ReiClient = class {
|
|
|
686
700
|
*
|
|
687
701
|
* @private
|
|
688
702
|
* @param {Response} res
|
|
689
|
-
* @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`.
|
|
690
711
|
* @returns {Promise<void>}
|
|
691
712
|
*/
|
|
692
713
|
async _consumeSseStream(res, opts) {
|
|
693
|
-
const { onPayload } = opts;
|
|
714
|
+
const { onPayload, onRawRead, responseMeta } = opts;
|
|
694
715
|
const reader = res.body.getReader();
|
|
695
716
|
const decoder = new TextDecoder();
|
|
696
717
|
let buffer = "";
|
|
697
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
|
+
};
|
|
698
744
|
const processFrame = async (part) => {
|
|
699
745
|
if (!part.trim()) return null;
|
|
700
746
|
let eventName = "message";
|
|
@@ -736,6 +782,7 @@ ${piece}` : piece;
|
|
|
736
782
|
try {
|
|
737
783
|
while (true) {
|
|
738
784
|
const { done, value } = await reader.read();
|
|
785
|
+
emitRawRead(done, value);
|
|
739
786
|
if (done) {
|
|
740
787
|
buffer += decoder.decode();
|
|
741
788
|
const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
|
|
@@ -871,10 +918,10 @@ ${piece}` : piece;
|
|
|
871
918
|
return Math.min(defaultGrace, remainingMs);
|
|
872
919
|
}
|
|
873
920
|
/**
|
|
874
|
-
* One-shot dev
|
|
875
|
-
* per call via `opts.expectsBackupPush === true
|
|
876
|
-
*
|
|
877
|
-
*
|
|
921
|
+
* One-shot dev reminder for low-level instant APIs. The warning is opt-in
|
|
922
|
+
* per call via `opts.expectsBackupPush === true` and fires at most once
|
|
923
|
+
* per ReiClient instance per method name. Default (omitted or `false`)
|
|
924
|
+
* is silent.
|
|
878
925
|
*
|
|
879
926
|
* @private
|
|
880
927
|
* @param {string} methodName
|
|
@@ -886,7 +933,7 @@ ${piece}` : piece;
|
|
|
886
933
|
this._lowLevelWarned.add(methodName);
|
|
887
934
|
const verdict = methodName === "sendInstant" ? "HTTP 200 \u2260 delivery confirmation" : "rejection \u2260 delivery failure";
|
|
888
935
|
console.warn(
|
|
889
|
-
`[rei-standard-amsg-client] ${methodName} is a low-level transport \u2014 ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict
|
|
936
|
+
`[rei-standard-amsg-client] ${methodName} is a low-level transport \u2014 ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict.`
|
|
890
937
|
);
|
|
891
938
|
}
|
|
892
939
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
@@ -900,7 +947,7 @@ ${piece}` : piece;
|
|
|
900
947
|
if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
|
|
901
948
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
902
949
|
const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
903
|
-
const encoded =
|
|
950
|
+
const encoded = TEXT_ENCODER.encode(plaintext);
|
|
904
951
|
const cipherBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
|
905
952
|
const cipherArr = new Uint8Array(cipherBuf);
|
|
906
953
|
const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
|
|
@@ -953,15 +1000,6 @@ ${piece}` : piece;
|
|
|
953
1000
|
}
|
|
954
1001
|
return arr;
|
|
955
1002
|
}
|
|
956
|
-
/** @private */
|
|
957
|
-
_urlBase64ToUint8Array(base64String) {
|
|
958
|
-
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
|
959
|
-
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
960
|
-
const raw = atob(base64);
|
|
961
|
-
const arr = new Uint8Array(raw.length);
|
|
962
|
-
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
963
|
-
return arr;
|
|
964
|
-
}
|
|
965
1003
|
};
|
|
966
1004
|
function normalizeMaxPayloadBytes(value) {
|
|
967
1005
|
if (value === void 0 || value === null) return null;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { base64UrlToBytes } from '@rei-standard/amsg-shared';
|
|
1
2
|
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -30,6 +31,11 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
|
|
|
30
31
|
* await client.scheduleMessage({ ... });
|
|
31
32
|
*/
|
|
32
33
|
|
|
34
|
+
|
|
35
|
+
// `TextEncoder` is stateless — hoist once instead of allocating a fresh
|
|
36
|
+
// instance for every encrypt + payload-size check.
|
|
37
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
38
|
+
|
|
33
39
|
/** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */
|
|
34
40
|
/** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */
|
|
35
41
|
/** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */
|
|
@@ -189,6 +195,29 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
|
|
|
189
195
|
* `deliver()` don't silently drop the header.
|
|
190
196
|
* @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass
|
|
191
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.
|
|
192
221
|
*/
|
|
193
222
|
|
|
194
223
|
/**
|
|
@@ -400,11 +429,11 @@ class ReiClient {
|
|
|
400
429
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
401
430
|
* @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
|
|
402
431
|
* - `authorization`: optional auth header to forward.
|
|
403
|
-
* - `expectsBackupPush`: opt-in
|
|
404
|
-
*
|
|
405
|
-
* "200 ≠
|
|
406
|
-
* (
|
|
407
|
-
*
|
|
432
|
+
* - `expectsBackupPush`: opt-in dev reminder. Set to `true` to log a
|
|
433
|
+
* one-shot console.warn that this is a low-level transport and
|
|
434
|
+
* "HTTP 200 ≠ delivery confirmation" once the worker has backup
|
|
435
|
+
* push enabled (amsg-instant 0.9.0+ default). Default (omitted) is
|
|
436
|
+
* silent.
|
|
408
437
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
409
438
|
*/
|
|
410
439
|
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
@@ -415,6 +444,10 @@ class ReiClient {
|
|
|
415
444
|
endpointPath,
|
|
416
445
|
{ authorization: opts.authorization, methodName: 'sendInstant' }
|
|
417
446
|
);
|
|
447
|
+
// Pin the response shape: amsg-instant routes the JSON `{ success, data }`
|
|
448
|
+
// envelope only when the caller asked exclusively for it. Omitting Accept
|
|
449
|
+
// gets the SSE branch and `res.json()` then throws on the SSE bytes.
|
|
450
|
+
headers['Accept'] = 'application/json';
|
|
418
451
|
|
|
419
452
|
const res = await fetch(url, { method: 'POST', headers, body });
|
|
420
453
|
return res.json();
|
|
@@ -447,10 +480,10 @@ class ReiClient {
|
|
|
447
480
|
* @param {(error: unknown) => void} [options.onError]
|
|
448
481
|
* @param {() => void} [options.onDone]
|
|
449
482
|
* @param {AbortSignal} [options.signal]
|
|
450
|
-
* @param {boolean} [options.expectsBackupPush] - Opt-in
|
|
451
|
-
*
|
|
452
|
-
* "
|
|
453
|
-
*
|
|
483
|
+
* @param {boolean} [options.expectsBackupPush] - Opt-in dev reminder. Set
|
|
484
|
+
* to `true` to log a one-shot console.warn that "rejection ≠ delivery
|
|
485
|
+
* failure" once the worker has backup push enabled (amsg-instant 0.9.0+
|
|
486
|
+
* default). Default (omitted) is silent.
|
|
454
487
|
* @returns {Promise<void>}
|
|
455
488
|
*/
|
|
456
489
|
async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
|
|
@@ -526,7 +559,7 @@ class ReiClient {
|
|
|
526
559
|
}
|
|
527
560
|
const {
|
|
528
561
|
delivery, timeoutMs, onChunk, postTransportGraceMs,
|
|
529
|
-
signal, headers, authorization, endpointPath,
|
|
562
|
+
signal, headers, authorization, endpointPath, onRawRead,
|
|
530
563
|
} = opts;
|
|
531
564
|
|
|
532
565
|
if (!delivery || typeof delivery !== 'object') {
|
|
@@ -619,6 +652,7 @@ class ReiClient {
|
|
|
619
652
|
const result = await this._runInstantTransport(built, {
|
|
620
653
|
signal: internalAbort.signal,
|
|
621
654
|
onChunk: wrappedOnChunk,
|
|
655
|
+
onRawRead,
|
|
622
656
|
});
|
|
623
657
|
if (finalized) return;
|
|
624
658
|
transportEnded = true;
|
|
@@ -860,7 +894,7 @@ class ReiClient {
|
|
|
860
894
|
async subscribePush(vapidPublicKey, registration) {
|
|
861
895
|
const subscription = await registration.pushManager.subscribe({
|
|
862
896
|
userVisibleOnly: true,
|
|
863
|
-
applicationServerKey:
|
|
897
|
+
applicationServerKey: base64UrlToBytes(vapidPublicKey)
|
|
864
898
|
});
|
|
865
899
|
return subscription;
|
|
866
900
|
}
|
|
@@ -911,7 +945,7 @@ class ReiClient {
|
|
|
911
945
|
*/
|
|
912
946
|
_assertPayloadSize(bodyJson, methodName) {
|
|
913
947
|
if (this._maxPayloadBytes == null) return;
|
|
914
|
-
const bytes =
|
|
948
|
+
const bytes = TEXT_ENCODER.encode(bodyJson).length;
|
|
915
949
|
if (bytes > this._maxPayloadBytes) {
|
|
916
950
|
throw makeLocalError(
|
|
917
951
|
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
@@ -971,11 +1005,12 @@ class ReiClient {
|
|
|
971
1005
|
*
|
|
972
1006
|
* @private
|
|
973
1007
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
974
|
-
* @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`).
|
|
975
1010
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
976
1011
|
*/
|
|
977
1012
|
async _runInstantTransport(built, opts) {
|
|
978
|
-
const { signal, onChunk } = opts;
|
|
1013
|
+
const { signal, onChunk, onRawRead } = opts;
|
|
979
1014
|
const { url, headers, body } = built;
|
|
980
1015
|
|
|
981
1016
|
const res = await fetch(url, { method: 'POST', headers, body, signal });
|
|
@@ -991,7 +1026,15 @@ class ReiClient {
|
|
|
991
1026
|
const kind = classifyContentType(contentType);
|
|
992
1027
|
if (kind === 'sse') {
|
|
993
1028
|
if (!res.body) throw new Error('Response body is null');
|
|
994
|
-
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
|
+
});
|
|
995
1038
|
return { kind: 'sse' };
|
|
996
1039
|
}
|
|
997
1040
|
if (kind === 'json') {
|
|
@@ -1009,16 +1052,54 @@ class ReiClient {
|
|
|
1009
1052
|
*
|
|
1010
1053
|
* @private
|
|
1011
1054
|
* @param {Response} res
|
|
1012
|
-
* @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`.
|
|
1013
1063
|
* @returns {Promise<void>}
|
|
1014
1064
|
*/
|
|
1015
1065
|
async _consumeSseStream(res, opts) {
|
|
1016
|
-
const { onPayload } = opts;
|
|
1066
|
+
const { onPayload, onRawRead, responseMeta } = opts;
|
|
1017
1067
|
const reader = res.body.getReader();
|
|
1018
1068
|
const decoder = new TextDecoder();
|
|
1019
1069
|
let buffer = '';
|
|
1020
1070
|
let thrown;
|
|
1021
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
|
+
|
|
1022
1103
|
// Parse one SSE frame body (lines between two terminators). Returns
|
|
1023
1104
|
// `'done'` if the frame signals end-of-stream so the caller can
|
|
1024
1105
|
// unwind without consuming further frames. Throws on `event: error`.
|
|
@@ -1061,6 +1142,7 @@ class ReiClient {
|
|
|
1061
1142
|
try {
|
|
1062
1143
|
while (true) {
|
|
1063
1144
|
const { done, value } = await reader.read();
|
|
1145
|
+
emitRawRead(done, value);
|
|
1064
1146
|
if (done) {
|
|
1065
1147
|
// Flush any tail bytes the decoder held back (partial UTF-8
|
|
1066
1148
|
// sequences split across the final chunk boundary).
|
|
@@ -1204,10 +1286,10 @@ class ReiClient {
|
|
|
1204
1286
|
}
|
|
1205
1287
|
|
|
1206
1288
|
/**
|
|
1207
|
-
* One-shot dev
|
|
1208
|
-
* per call via `opts.expectsBackupPush === true
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1289
|
+
* One-shot dev reminder for low-level instant APIs. The warning is opt-in
|
|
1290
|
+
* per call via `opts.expectsBackupPush === true` and fires at most once
|
|
1291
|
+
* per ReiClient instance per method name. Default (omitted or `false`)
|
|
1292
|
+
* is silent.
|
|
1211
1293
|
*
|
|
1212
1294
|
* @private
|
|
1213
1295
|
* @param {string} methodName
|
|
@@ -1221,7 +1303,7 @@ class ReiClient {
|
|
|
1221
1303
|
? 'HTTP 200 ≠ delivery confirmation'
|
|
1222
1304
|
: 'rejection ≠ delivery failure';
|
|
1223
1305
|
console.warn(
|
|
1224
|
-
`[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict
|
|
1306
|
+
`[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict.`
|
|
1225
1307
|
);
|
|
1226
1308
|
}
|
|
1227
1309
|
|
|
@@ -1238,7 +1320,7 @@ class ReiClient {
|
|
|
1238
1320
|
|
|
1239
1321
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1240
1322
|
const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']);
|
|
1241
|
-
const encoded =
|
|
1323
|
+
const encoded = TEXT_ENCODER.encode(plaintext);
|
|
1242
1324
|
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
|
1243
1325
|
|
|
1244
1326
|
// Web Crypto appends the 16-byte auth tag at the end of the ciphertext
|
|
@@ -1302,15 +1384,6 @@ class ReiClient {
|
|
|
1302
1384
|
return arr;
|
|
1303
1385
|
}
|
|
1304
1386
|
|
|
1305
|
-
/** @private */
|
|
1306
|
-
_urlBase64ToUint8Array(base64String) {
|
|
1307
|
-
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
1308
|
-
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
1309
|
-
const raw = atob(base64);
|
|
1310
|
-
const arr = new Uint8Array(raw.length);
|
|
1311
|
-
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
1312
|
-
return arr;
|
|
1313
|
-
}
|
|
1314
1387
|
}
|
|
1315
1388
|
|
|
1316
1389
|
function normalizeMaxPayloadBytes(value) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { base64UrlToBytes } from '@rei-standard/amsg-shared';
|
|
1
2
|
export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPush, buildReasoningPush, buildToolRequestPush, isContentPush, isErrorPush, isReasoningPush, isToolRequestPush } from '@rei-standard/amsg-shared';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -30,6 +31,11 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
|
|
|
30
31
|
* await client.scheduleMessage({ ... });
|
|
31
32
|
*/
|
|
32
33
|
|
|
34
|
+
|
|
35
|
+
// `TextEncoder` is stateless — hoist once instead of allocating a fresh
|
|
36
|
+
// instance for every encrypt + payload-size check.
|
|
37
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
38
|
+
|
|
33
39
|
/** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */
|
|
34
40
|
/** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */
|
|
35
41
|
/** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */
|
|
@@ -189,6 +195,29 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
|
|
|
189
195
|
* `deliver()` don't silently drop the header.
|
|
190
196
|
* @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass
|
|
191
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.
|
|
192
221
|
*/
|
|
193
222
|
|
|
194
223
|
/**
|
|
@@ -400,11 +429,11 @@ class ReiClient {
|
|
|
400
429
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
401
430
|
* @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
|
|
402
431
|
* - `authorization`: optional auth header to forward.
|
|
403
|
-
* - `expectsBackupPush`: opt-in
|
|
404
|
-
*
|
|
405
|
-
* "200 ≠
|
|
406
|
-
* (
|
|
407
|
-
*
|
|
432
|
+
* - `expectsBackupPush`: opt-in dev reminder. Set to `true` to log a
|
|
433
|
+
* one-shot console.warn that this is a low-level transport and
|
|
434
|
+
* "HTTP 200 ≠ delivery confirmation" once the worker has backup
|
|
435
|
+
* push enabled (amsg-instant 0.9.0+ default). Default (omitted) is
|
|
436
|
+
* silent.
|
|
408
437
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
409
438
|
*/
|
|
410
439
|
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
@@ -415,6 +444,10 @@ class ReiClient {
|
|
|
415
444
|
endpointPath,
|
|
416
445
|
{ authorization: opts.authorization, methodName: 'sendInstant' }
|
|
417
446
|
);
|
|
447
|
+
// Pin the response shape: amsg-instant routes the JSON `{ success, data }`
|
|
448
|
+
// envelope only when the caller asked exclusively for it. Omitting Accept
|
|
449
|
+
// gets the SSE branch and `res.json()` then throws on the SSE bytes.
|
|
450
|
+
headers['Accept'] = 'application/json';
|
|
418
451
|
|
|
419
452
|
const res = await fetch(url, { method: 'POST', headers, body });
|
|
420
453
|
return res.json();
|
|
@@ -447,10 +480,10 @@ class ReiClient {
|
|
|
447
480
|
* @param {(error: unknown) => void} [options.onError]
|
|
448
481
|
* @param {() => void} [options.onDone]
|
|
449
482
|
* @param {AbortSignal} [options.signal]
|
|
450
|
-
* @param {boolean} [options.expectsBackupPush] - Opt-in
|
|
451
|
-
*
|
|
452
|
-
* "
|
|
453
|
-
*
|
|
483
|
+
* @param {boolean} [options.expectsBackupPush] - Opt-in dev reminder. Set
|
|
484
|
+
* to `true` to log a one-shot console.warn that "rejection ≠ delivery
|
|
485
|
+
* failure" once the worker has backup push enabled (amsg-instant 0.9.0+
|
|
486
|
+
* default). Default (omitted) is silent.
|
|
454
487
|
* @returns {Promise<void>}
|
|
455
488
|
*/
|
|
456
489
|
async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
|
|
@@ -526,7 +559,7 @@ class ReiClient {
|
|
|
526
559
|
}
|
|
527
560
|
const {
|
|
528
561
|
delivery, timeoutMs, onChunk, postTransportGraceMs,
|
|
529
|
-
signal, headers, authorization, endpointPath,
|
|
562
|
+
signal, headers, authorization, endpointPath, onRawRead,
|
|
530
563
|
} = opts;
|
|
531
564
|
|
|
532
565
|
if (!delivery || typeof delivery !== 'object') {
|
|
@@ -619,6 +652,7 @@ class ReiClient {
|
|
|
619
652
|
const result = await this._runInstantTransport(built, {
|
|
620
653
|
signal: internalAbort.signal,
|
|
621
654
|
onChunk: wrappedOnChunk,
|
|
655
|
+
onRawRead,
|
|
622
656
|
});
|
|
623
657
|
if (finalized) return;
|
|
624
658
|
transportEnded = true;
|
|
@@ -860,7 +894,7 @@ class ReiClient {
|
|
|
860
894
|
async subscribePush(vapidPublicKey, registration) {
|
|
861
895
|
const subscription = await registration.pushManager.subscribe({
|
|
862
896
|
userVisibleOnly: true,
|
|
863
|
-
applicationServerKey:
|
|
897
|
+
applicationServerKey: base64UrlToBytes(vapidPublicKey)
|
|
864
898
|
});
|
|
865
899
|
return subscription;
|
|
866
900
|
}
|
|
@@ -911,7 +945,7 @@ class ReiClient {
|
|
|
911
945
|
*/
|
|
912
946
|
_assertPayloadSize(bodyJson, methodName) {
|
|
913
947
|
if (this._maxPayloadBytes == null) return;
|
|
914
|
-
const bytes =
|
|
948
|
+
const bytes = TEXT_ENCODER.encode(bodyJson).length;
|
|
915
949
|
if (bytes > this._maxPayloadBytes) {
|
|
916
950
|
throw makeLocalError(
|
|
917
951
|
'PAYLOAD_TOO_LARGE_LOCAL',
|
|
@@ -971,11 +1005,12 @@ class ReiClient {
|
|
|
971
1005
|
*
|
|
972
1006
|
* @private
|
|
973
1007
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
974
|
-
* @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`).
|
|
975
1010
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
976
1011
|
*/
|
|
977
1012
|
async _runInstantTransport(built, opts) {
|
|
978
|
-
const { signal, onChunk } = opts;
|
|
1013
|
+
const { signal, onChunk, onRawRead } = opts;
|
|
979
1014
|
const { url, headers, body } = built;
|
|
980
1015
|
|
|
981
1016
|
const res = await fetch(url, { method: 'POST', headers, body, signal });
|
|
@@ -991,7 +1026,15 @@ class ReiClient {
|
|
|
991
1026
|
const kind = classifyContentType(contentType);
|
|
992
1027
|
if (kind === 'sse') {
|
|
993
1028
|
if (!res.body) throw new Error('Response body is null');
|
|
994
|
-
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
|
+
});
|
|
995
1038
|
return { kind: 'sse' };
|
|
996
1039
|
}
|
|
997
1040
|
if (kind === 'json') {
|
|
@@ -1009,16 +1052,54 @@ class ReiClient {
|
|
|
1009
1052
|
*
|
|
1010
1053
|
* @private
|
|
1011
1054
|
* @param {Response} res
|
|
1012
|
-
* @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`.
|
|
1013
1063
|
* @returns {Promise<void>}
|
|
1014
1064
|
*/
|
|
1015
1065
|
async _consumeSseStream(res, opts) {
|
|
1016
|
-
const { onPayload } = opts;
|
|
1066
|
+
const { onPayload, onRawRead, responseMeta } = opts;
|
|
1017
1067
|
const reader = res.body.getReader();
|
|
1018
1068
|
const decoder = new TextDecoder();
|
|
1019
1069
|
let buffer = '';
|
|
1020
1070
|
let thrown;
|
|
1021
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
|
+
|
|
1022
1103
|
// Parse one SSE frame body (lines between two terminators). Returns
|
|
1023
1104
|
// `'done'` if the frame signals end-of-stream so the caller can
|
|
1024
1105
|
// unwind without consuming further frames. Throws on `event: error`.
|
|
@@ -1061,6 +1142,7 @@ class ReiClient {
|
|
|
1061
1142
|
try {
|
|
1062
1143
|
while (true) {
|
|
1063
1144
|
const { done, value } = await reader.read();
|
|
1145
|
+
emitRawRead(done, value);
|
|
1064
1146
|
if (done) {
|
|
1065
1147
|
// Flush any tail bytes the decoder held back (partial UTF-8
|
|
1066
1148
|
// sequences split across the final chunk boundary).
|
|
@@ -1204,10 +1286,10 @@ class ReiClient {
|
|
|
1204
1286
|
}
|
|
1205
1287
|
|
|
1206
1288
|
/**
|
|
1207
|
-
* One-shot dev
|
|
1208
|
-
* per call via `opts.expectsBackupPush === true
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1289
|
+
* One-shot dev reminder for low-level instant APIs. The warning is opt-in
|
|
1290
|
+
* per call via `opts.expectsBackupPush === true` and fires at most once
|
|
1291
|
+
* per ReiClient instance per method name. Default (omitted or `false`)
|
|
1292
|
+
* is silent.
|
|
1211
1293
|
*
|
|
1212
1294
|
* @private
|
|
1213
1295
|
* @param {string} methodName
|
|
@@ -1221,7 +1303,7 @@ class ReiClient {
|
|
|
1221
1303
|
? 'HTTP 200 ≠ delivery confirmation'
|
|
1222
1304
|
: 'rejection ≠ delivery failure';
|
|
1223
1305
|
console.warn(
|
|
1224
|
-
`[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict
|
|
1306
|
+
`[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict.`
|
|
1225
1307
|
);
|
|
1226
1308
|
}
|
|
1227
1309
|
|
|
@@ -1238,7 +1320,7 @@ class ReiClient {
|
|
|
1238
1320
|
|
|
1239
1321
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1240
1322
|
const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']);
|
|
1241
|
-
const encoded =
|
|
1323
|
+
const encoded = TEXT_ENCODER.encode(plaintext);
|
|
1242
1324
|
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
|
1243
1325
|
|
|
1244
1326
|
// Web Crypto appends the 16-byte auth tag at the end of the ciphertext
|
|
@@ -1302,15 +1384,6 @@ class ReiClient {
|
|
|
1302
1384
|
return arr;
|
|
1303
1385
|
}
|
|
1304
1386
|
|
|
1305
|
-
/** @private */
|
|
1306
|
-
_urlBase64ToUint8Array(base64String) {
|
|
1307
|
-
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
1308
|
-
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
1309
|
-
const raw = atob(base64);
|
|
1310
|
-
const arr = new Uint8Array(raw.length);
|
|
1311
|
-
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
1312
|
-
return arr;
|
|
1313
|
-
}
|
|
1314
1387
|
}
|
|
1315
1388
|
|
|
1316
1389
|
function normalizeMaxPayloadBytes(value) {
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/index.js
|
|
2
|
+
import { base64UrlToBytes } from "@rei-standard/amsg-shared";
|
|
2
3
|
import {
|
|
3
4
|
MESSAGE_KIND,
|
|
4
5
|
MESSAGE_TYPE,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
isToolRequestPush,
|
|
13
14
|
isErrorPush
|
|
14
15
|
} from "@rei-standard/amsg-shared";
|
|
16
|
+
var TEXT_ENCODER = new TextEncoder();
|
|
15
17
|
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
16
18
|
function makeLocalError(code, message, details) {
|
|
17
19
|
const err = new Error(`[rei-standard-amsg-client] ${message}`);
|
|
@@ -165,11 +167,11 @@ var ReiClient = class {
|
|
|
165
167
|
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
166
168
|
* @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
|
|
167
169
|
* - `authorization`: optional auth header to forward.
|
|
168
|
-
* - `expectsBackupPush`: opt-in
|
|
169
|
-
*
|
|
170
|
-
* "200 ≠
|
|
171
|
-
* (
|
|
172
|
-
*
|
|
170
|
+
* - `expectsBackupPush`: opt-in dev reminder. Set to `true` to log a
|
|
171
|
+
* one-shot console.warn that this is a low-level transport and
|
|
172
|
+
* "HTTP 200 ≠ delivery confirmation" once the worker has backup
|
|
173
|
+
* push enabled (amsg-instant 0.9.0+ default). Default (omitted) is
|
|
174
|
+
* silent.
|
|
173
175
|
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
174
176
|
*/
|
|
175
177
|
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
@@ -179,6 +181,7 @@ var ReiClient = class {
|
|
|
179
181
|
endpointPath,
|
|
180
182
|
{ authorization: opts.authorization, methodName: "sendInstant" }
|
|
181
183
|
);
|
|
184
|
+
headers["Accept"] = "application/json";
|
|
182
185
|
const res = await fetch(url, { method: "POST", headers, body });
|
|
183
186
|
return res.json();
|
|
184
187
|
}
|
|
@@ -209,10 +212,10 @@ var ReiClient = class {
|
|
|
209
212
|
* @param {(error: unknown) => void} [options.onError]
|
|
210
213
|
* @param {() => void} [options.onDone]
|
|
211
214
|
* @param {AbortSignal} [options.signal]
|
|
212
|
-
* @param {boolean} [options.expectsBackupPush] - Opt-in
|
|
213
|
-
*
|
|
214
|
-
* "
|
|
215
|
-
*
|
|
215
|
+
* @param {boolean} [options.expectsBackupPush] - Opt-in dev reminder. Set
|
|
216
|
+
* to `true` to log a one-shot console.warn that "rejection ≠ delivery
|
|
217
|
+
* failure" once the worker has backup push enabled (amsg-instant 0.9.0+
|
|
218
|
+
* default). Default (omitted) is silent.
|
|
216
219
|
* @returns {Promise<void>}
|
|
217
220
|
*/
|
|
218
221
|
async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
|
|
@@ -290,7 +293,8 @@ var ReiClient = class {
|
|
|
290
293
|
signal,
|
|
291
294
|
headers,
|
|
292
295
|
authorization,
|
|
293
|
-
endpointPath
|
|
296
|
+
endpointPath,
|
|
297
|
+
onRawRead
|
|
294
298
|
} = opts;
|
|
295
299
|
if (!delivery || typeof delivery !== "object") {
|
|
296
300
|
throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
|
|
@@ -351,7 +355,8 @@ var ReiClient = class {
|
|
|
351
355
|
try {
|
|
352
356
|
const result = await this._runInstantTransport(built, {
|
|
353
357
|
signal: internalAbort.signal,
|
|
354
|
-
onChunk: wrappedOnChunk
|
|
358
|
+
onChunk: wrappedOnChunk,
|
|
359
|
+
onRawRead
|
|
355
360
|
});
|
|
356
361
|
if (finalized) return;
|
|
357
362
|
transportEnded = true;
|
|
@@ -529,7 +534,7 @@ var ReiClient = class {
|
|
|
529
534
|
async subscribePush(vapidPublicKey, registration) {
|
|
530
535
|
const subscription = await registration.pushManager.subscribe({
|
|
531
536
|
userVisibleOnly: true,
|
|
532
|
-
applicationServerKey:
|
|
537
|
+
applicationServerKey: base64UrlToBytes(vapidPublicKey)
|
|
533
538
|
});
|
|
534
539
|
return subscription;
|
|
535
540
|
}
|
|
@@ -577,7 +582,7 @@ var ReiClient = class {
|
|
|
577
582
|
*/
|
|
578
583
|
_assertPayloadSize(bodyJson, methodName) {
|
|
579
584
|
if (this._maxPayloadBytes == null) return;
|
|
580
|
-
const bytes =
|
|
585
|
+
const bytes = TEXT_ENCODER.encode(bodyJson).length;
|
|
581
586
|
if (bytes > this._maxPayloadBytes) {
|
|
582
587
|
throw makeLocalError(
|
|
583
588
|
"PAYLOAD_TOO_LARGE_LOCAL",
|
|
@@ -630,11 +635,12 @@ var ReiClient = class {
|
|
|
630
635
|
*
|
|
631
636
|
* @private
|
|
632
637
|
* @param {{ url: string, headers: Record<string, string>, body: string }} built
|
|
633
|
-
* @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`).
|
|
634
640
|
* @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
|
|
635
641
|
*/
|
|
636
642
|
async _runInstantTransport(built, opts) {
|
|
637
|
-
const { signal, onChunk } = opts;
|
|
643
|
+
const { signal, onChunk, onRawRead } = opts;
|
|
638
644
|
const { url, headers, body } = built;
|
|
639
645
|
const res = await fetch(url, { method: "POST", headers, body, signal });
|
|
640
646
|
if (!res.ok) {
|
|
@@ -647,7 +653,15 @@ var ReiClient = class {
|
|
|
647
653
|
const kind = classifyContentType(contentType);
|
|
648
654
|
if (kind === "sse") {
|
|
649
655
|
if (!res.body) throw new Error("Response body is null");
|
|
650
|
-
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
|
+
});
|
|
651
665
|
return { kind: "sse" };
|
|
652
666
|
}
|
|
653
667
|
if (kind === "json") {
|
|
@@ -664,15 +678,47 @@ var ReiClient = class {
|
|
|
664
678
|
*
|
|
665
679
|
* @private
|
|
666
680
|
* @param {Response} res
|
|
667
|
-
* @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`.
|
|
668
689
|
* @returns {Promise<void>}
|
|
669
690
|
*/
|
|
670
691
|
async _consumeSseStream(res, opts) {
|
|
671
|
-
const { onPayload } = opts;
|
|
692
|
+
const { onPayload, onRawRead, responseMeta } = opts;
|
|
672
693
|
const reader = res.body.getReader();
|
|
673
694
|
const decoder = new TextDecoder();
|
|
674
695
|
let buffer = "";
|
|
675
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
|
+
};
|
|
676
722
|
const processFrame = async (part) => {
|
|
677
723
|
if (!part.trim()) return null;
|
|
678
724
|
let eventName = "message";
|
|
@@ -714,6 +760,7 @@ ${piece}` : piece;
|
|
|
714
760
|
try {
|
|
715
761
|
while (true) {
|
|
716
762
|
const { done, value } = await reader.read();
|
|
763
|
+
emitRawRead(done, value);
|
|
717
764
|
if (done) {
|
|
718
765
|
buffer += decoder.decode();
|
|
719
766
|
const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
|
|
@@ -849,10 +896,10 @@ ${piece}` : piece;
|
|
|
849
896
|
return Math.min(defaultGrace, remainingMs);
|
|
850
897
|
}
|
|
851
898
|
/**
|
|
852
|
-
* One-shot dev
|
|
853
|
-
* per call via `opts.expectsBackupPush === true
|
|
854
|
-
*
|
|
855
|
-
*
|
|
899
|
+
* One-shot dev reminder for low-level instant APIs. The warning is opt-in
|
|
900
|
+
* per call via `opts.expectsBackupPush === true` and fires at most once
|
|
901
|
+
* per ReiClient instance per method name. Default (omitted or `false`)
|
|
902
|
+
* is silent.
|
|
856
903
|
*
|
|
857
904
|
* @private
|
|
858
905
|
* @param {string} methodName
|
|
@@ -864,7 +911,7 @@ ${piece}` : piece;
|
|
|
864
911
|
this._lowLevelWarned.add(methodName);
|
|
865
912
|
const verdict = methodName === "sendInstant" ? "HTTP 200 \u2260 delivery confirmation" : "rejection \u2260 delivery failure";
|
|
866
913
|
console.warn(
|
|
867
|
-
`[rei-standard-amsg-client] ${methodName} is a low-level transport \u2014 ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict
|
|
914
|
+
`[rei-standard-amsg-client] ${methodName} is a low-level transport \u2014 ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict.`
|
|
868
915
|
);
|
|
869
916
|
}
|
|
870
917
|
// ─── Crypto helpers (Web Crypto API) ────────────────────────────
|
|
@@ -878,7 +925,7 @@ ${piece}` : piece;
|
|
|
878
925
|
if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
|
|
879
926
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
880
927
|
const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
881
|
-
const encoded =
|
|
928
|
+
const encoded = TEXT_ENCODER.encode(plaintext);
|
|
882
929
|
const cipherBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
|
883
930
|
const cipherArr = new Uint8Array(cipherBuf);
|
|
884
931
|
const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
|
|
@@ -931,15 +978,6 @@ ${piece}` : piece;
|
|
|
931
978
|
}
|
|
932
979
|
return arr;
|
|
933
980
|
}
|
|
934
|
-
/** @private */
|
|
935
|
-
_urlBase64ToUint8Array(base64String) {
|
|
936
|
-
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
|
937
|
-
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
938
|
-
const raw = atob(base64);
|
|
939
|
-
const arr = new Uint8Array(raw.length);
|
|
940
|
-
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
941
|
-
return arr;
|
|
942
|
-
}
|
|
943
981
|
};
|
|
944
982
|
function normalizeMaxPayloadBytes(value) {
|
|
945
983
|
if (value === void 0 || value === null) return null;
|
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",
|