@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 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: () => import_amsg_shared.MESSAGE_KIND,
23
- MESSAGE_TYPE: () => import_amsg_shared.MESSAGE_TYPE,
24
- PUSH_SOURCE: () => import_amsg_shared.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: () => import_amsg_shared.buildContentPush,
27
- buildErrorPush: () => import_amsg_shared.buildErrorPush,
28
- buildReasoningPush: () => import_amsg_shared.buildReasoningPush,
29
- buildToolRequestPush: () => import_amsg_shared.buildToolRequestPush,
30
- isContentPush: () => import_amsg_shared.isContentPush,
31
- isErrorPush: () => import_amsg_shared.isErrorPush,
32
- isReasoningPush: () => import_amsg_shared.isReasoningPush,
33
- isToolRequestPush: () => import_amsg_shared.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 flag for the dev warning. Set to `true`
191
- * to log a one-shot console.warn confirming you understand the
192
- * "200 ≠ delivered" pitfall and have your own out-of-band check
193
- * (or migrated to `deliver()`). Set to `false` to explicitly silence
194
- * the warning (you have read this contract).
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 flag for the dev warning.
235
- * Set to `true` to log a one-shot console.warn confirming you understand the
236
- * "rejection delivery failure" pitfall and have your own check (or migrated
237
- * to `deliver()`). Set to `false` to explicitly silence the warning.
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: this._urlBase64ToUint8Array(vapidPublicKey)
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 = new TextEncoder().encode(bodyJson).length;
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, { onPayload: onChunk });
678
+ await this._consumeSseStream(res, {
679
+ onPayload: onChunk,
680
+ onRawRead,
681
+ responseMeta: {
682
+ status: res.status,
683
+ contentEncoding: res.headers.get("content-encoding"),
684
+ contentType: res.headers.get("content-type")
685
+ }
686
+ });
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 {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
703
+ * @param {{
704
+ * onPayload?: (p: unknown) => Promise<void> | void,
705
+ * onRawRead?: (meta: RawReadMeta) => void,
706
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
707
+ * }} opts
708
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
709
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
710
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
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 warning for low-level instant APIs. The warning is opt-in
875
- * per call via `opts.expectsBackupPush === true`, and can be explicitly
876
- * silenced via `opts.expectsBackupPush === false`. Fires at most once per
877
- * ReiClient instance per method name.
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. Pass expectsBackupPush: false to silence this warning.`
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 = new TextEncoder().encode(plaintext);
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 flag for the dev warning. Set to `true`
404
- * to log a one-shot console.warn confirming you understand the
405
- * "200 ≠ delivered" pitfall and have your own out-of-band check
406
- * (or migrated to `deliver()`). Set to `false` to explicitly silence
407
- * the warning (you have read this contract).
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 flag for the dev warning.
451
- * Set to `true` to log a one-shot console.warn confirming you understand the
452
- * "rejection delivery failure" pitfall and have your own check (or migrated
453
- * to `deliver()`). Set to `false` to explicitly silence the warning.
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: this._urlBase64ToUint8Array(vapidPublicKey)
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 = new TextEncoder().encode(bodyJson).length;
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, { onPayload: onChunk });
1029
+ await this._consumeSseStream(res, {
1030
+ onPayload: onChunk,
1031
+ onRawRead,
1032
+ responseMeta: {
1033
+ status: res.status,
1034
+ contentEncoding: res.headers.get('content-encoding'),
1035
+ contentType: res.headers.get('content-type'),
1036
+ },
1037
+ });
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 {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1055
+ * @param {{
1056
+ * onPayload?: (p: unknown) => Promise<void> | void,
1057
+ * onRawRead?: (meta: RawReadMeta) => void,
1058
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1059
+ * }} opts
1060
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1061
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1062
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
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 warning for low-level instant APIs. The warning is opt-in
1208
- * per call via `opts.expectsBackupPush === true`, and can be explicitly
1209
- * silenced via `opts.expectsBackupPush === false`. Fires at most once per
1210
- * ReiClient instance per method name.
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. Pass expectsBackupPush: false to silence this warning.`
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 = new TextEncoder().encode(plaintext);
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 flag for the dev warning. Set to `true`
404
- * to log a one-shot console.warn confirming you understand the
405
- * "200 ≠ delivered" pitfall and have your own out-of-band check
406
- * (or migrated to `deliver()`). Set to `false` to explicitly silence
407
- * the warning (you have read this contract).
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 flag for the dev warning.
451
- * Set to `true` to log a one-shot console.warn confirming you understand the
452
- * "rejection delivery failure" pitfall and have your own check (or migrated
453
- * to `deliver()`). Set to `false` to explicitly silence the warning.
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: this._urlBase64ToUint8Array(vapidPublicKey)
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 = new TextEncoder().encode(bodyJson).length;
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, { onPayload: onChunk });
1029
+ await this._consumeSseStream(res, {
1030
+ onPayload: onChunk,
1031
+ onRawRead,
1032
+ responseMeta: {
1033
+ status: res.status,
1034
+ contentEncoding: res.headers.get('content-encoding'),
1035
+ contentType: res.headers.get('content-type'),
1036
+ },
1037
+ });
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 {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1055
+ * @param {{
1056
+ * onPayload?: (p: unknown) => Promise<void> | void,
1057
+ * onRawRead?: (meta: RawReadMeta) => void,
1058
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
1059
+ * }} opts
1060
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
1061
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
1062
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
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 warning for low-level instant APIs. The warning is opt-in
1208
- * per call via `opts.expectsBackupPush === true`, and can be explicitly
1209
- * silenced via `opts.expectsBackupPush === false`. Fires at most once per
1210
- * ReiClient instance per method name.
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. Pass expectsBackupPush: false to silence this warning.`
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 = new TextEncoder().encode(plaintext);
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 flag for the dev warning. Set to `true`
169
- * to log a one-shot console.warn confirming you understand the
170
- * "200 ≠ delivered" pitfall and have your own out-of-band check
171
- * (or migrated to `deliver()`). Set to `false` to explicitly silence
172
- * the warning (you have read this contract).
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 flag for the dev warning.
213
- * Set to `true` to log a one-shot console.warn confirming you understand the
214
- * "rejection delivery failure" pitfall and have your own check (or migrated
215
- * to `deliver()`). Set to `false` to explicitly silence the warning.
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: this._urlBase64ToUint8Array(vapidPublicKey)
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 = new TextEncoder().encode(bodyJson).length;
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, { onPayload: onChunk });
656
+ await this._consumeSseStream(res, {
657
+ onPayload: onChunk,
658
+ onRawRead,
659
+ responseMeta: {
660
+ status: res.status,
661
+ contentEncoding: res.headers.get("content-encoding"),
662
+ contentType: res.headers.get("content-type")
663
+ }
664
+ });
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 {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
681
+ * @param {{
682
+ * onPayload?: (p: unknown) => Promise<void> | void,
683
+ * onRawRead?: (meta: RawReadMeta) => void,
684
+ * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null }
685
+ * }} opts
686
+ * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees
687
+ * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is
688
+ * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`.
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 warning for low-level instant APIs. The warning is opt-in
853
- * per call via `opts.expectsBackupPush === true`, and can be explicitly
854
- * silenced via `opts.expectsBackupPush === false`. Fires at most once per
855
- * ReiClient instance per method name.
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. Pass expectsBackupPush: false to silence this warning.`
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 = new TextEncoder().encode(plaintext);
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.5.0-next.0",
3
+ "version": "2.6.0",
4
4
  "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared",
5
5
  "repository": {
6
6
  "type": "git",