@rei-standard/amsg-client 2.4.0 → 2.5.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/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}`);
@@ -19,6 +21,17 @@ function makeLocalError(code, message, details) {
19
21
  if (details) err.details = details;
20
22
  return err;
21
23
  }
24
+ function isThenable(value) {
25
+ return !!value && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
26
+ }
27
+ var SSE_LINE_NORMALIZE = /\r\n?/g;
28
+ function classifyContentType(contentType) {
29
+ const main = (contentType || "").split(";")[0].trim().toLowerCase();
30
+ if (main === "text/event-stream") return "sse";
31
+ if (main === "application/json") return "json";
32
+ if (/^application\/[\w.+-]+\+json$/.test(main)) return "json";
33
+ return "unknown";
34
+ }
22
35
  var ReiClient = class {
23
36
  /**
24
37
  * @param {ReiClientConfig} config
@@ -45,6 +58,7 @@ var ReiClient = class {
45
58
  this._instantEncryption = instantEncryption;
46
59
  this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
47
60
  this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
61
+ this._lowLevelWarned = /* @__PURE__ */ new Set();
48
62
  }
49
63
  /**
50
64
  * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
@@ -62,10 +76,11 @@ var ReiClient = class {
62
76
  * Must be called before any encrypted request.
63
77
  *
64
78
  * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
65
- * `sendInstant()` does not need a userKey. Note that if you also intend to
66
- * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
67
- * use AES-256-GCM), you must construct with `instantEncryption: true`
68
- * (the default) — those methods will throw "Not initialised" otherwise.
79
+ * `sendInstant()` / `deliver()` do not need a userKey. Note that if you
80
+ * also intend to call `scheduleMessage` / `listMessages` / `updateMessage`
81
+ * (which always use AES-256-GCM), you must construct with
82
+ * `instantEncryption: true` (the default) — those methods will throw
83
+ * "Not initialised" otherwise.
69
84
  */
70
85
  async init() {
71
86
  if (this._instantEncryption === false) {
@@ -87,11 +102,11 @@ var ReiClient = class {
87
102
  /**
88
103
  * Schedule a message.
89
104
  *
90
- * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
91
- * That routes through `@rei-standard/amsg-instant` (stateless, no DB
92
- * round-trip) rather than `amsg-server`'s schedule-message endpoint.
93
- * This method still works for instant via amsg-server for backward
94
- * compatibility — see CHANGELOG / README for details.
105
+ * Note: For `messageType: 'instant'`, prefer `deliver()` (2.5.0+) or
106
+ * `sendInstant()`. Both route through `@rei-standard/amsg-instant`
107
+ * (stateless, no DB round-trip) rather than `amsg-server`'s schedule-
108
+ * message endpoint. This method still works for instant via amsg-server
109
+ * for backward compatibility — see CHANGELOG / README for details.
95
110
  *
96
111
  * The payload is automatically encrypted before transmission.
97
112
  *
@@ -121,12 +136,19 @@ var ReiClient = class {
121
136
  return res.json();
122
137
  }
123
138
  /**
124
- * Send a one-shot instant message via `@rei-standard/amsg-instant`.
139
+ * **Low-level JSON dispatcher.** Use `deliver()` for new code — it
140
+ * gives you a correct `send-failed` vs `delivered` verdict by
141
+ * coordinating transport with an out-of-band observation channel.
125
142
  *
126
- * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
127
- * - No DB round-trip on the server side (stateless)
128
- * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
129
- * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
143
+ * Posts an instant message via `@rei-standard/amsg-instant` and
144
+ * returns whatever the worker returns. **HTTP 200 ≠ delivery
145
+ * confirmation** when amsg-instant is configured with backup Web
146
+ * Push (default in 0.9.0+): the dispatch succeeded but the message
147
+ * may still land via the backup channel even if this call rejected,
148
+ * and a 200 here does not guarantee the consumer ever saw it. If
149
+ * you only care about the transport response (no delivery
150
+ * coordination needed), this stays useful — otherwise prefer
151
+ * `deliver()`.
130
152
  *
131
153
  * Two transport modes (chosen by constructor `instantEncryption`):
132
154
  *
@@ -136,59 +158,51 @@ var ReiClient = class {
136
158
  * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
137
159
  *
138
160
  * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
139
- * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
161
+ * JSON. Targets amsg-instant 0.2.x+. Sends `X-Client-Token` if
140
162
  * `instantClientToken` was configured.
141
163
  *
142
164
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
143
165
  *
144
- * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
145
- * client soft-strips it on the payload and emits a `console.warn` — the
146
- * push still ships, just without an icon. If `maxPayloadBytes` is
147
- * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
148
- *
149
166
  * @param {Object} payload - Instant message payload.
150
167
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
151
- * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
168
+ * @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
169
+ * - `authorization`: optional auth header to forward.
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.
152
175
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
153
176
  */
154
177
  async sendInstant(payload, endpointPath = "/instant", opts = {}) {
155
- this._sanitizeAvatarUrl(payload);
156
- const json = JSON.stringify(payload);
157
- this._assertPayloadSize(json, "sendInstant");
158
- const headers = { "Content-Type": "application/json" };
159
- let body;
160
- if (this._instantEncryption === false) {
161
- body = json;
162
- if (this._instantClientToken) {
163
- headers["X-Client-Token"] = this._instantClientToken;
164
- }
165
- } else {
166
- const encrypted = await this._encrypt(json);
167
- headers["X-User-Id"] = this._userId;
168
- headers["X-Payload-Encrypted"] = "true";
169
- headers["X-Encryption-Version"] = "1";
170
- body = JSON.stringify(encrypted);
171
- }
172
- if (opts.authorization) {
173
- headers["Authorization"] = opts.authorization;
174
- }
175
- const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
176
- const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
177
- method: "POST",
178
- headers,
179
- body
180
- });
178
+ this._maybeWarnLowLevel("sendInstant", opts);
179
+ const { url, headers, body } = await this._buildInstantRequest(
180
+ payload,
181
+ endpointPath,
182
+ { authorization: opts.authorization, methodName: "sendInstant" }
183
+ );
184
+ headers["Accept"] = "application/json";
185
+ const res = await fetch(url, { method: "POST", headers, body });
181
186
  return res.json();
182
187
  }
183
188
  /**
184
- * Consume an instant SSE stream.
189
+ * **Low-level SSE consumer.** Use `deliver()` for new code — it gives
190
+ * you a correct `send-failed` vs `delivered` verdict by coordinating
191
+ * transport with an out-of-band observation channel.
192
+ *
193
+ * **Rejection ≠ delivery failure** when amsg-instant is configured
194
+ * with backup Web Push (default in 0.9.0+): SSE may reject for many
195
+ * unrelated reasons (iOS background tab killed fetch, network blip,
196
+ * worker 5xx) while the backup push still lands the message. Treating
197
+ * the rejection as the canonical error path is wrong for that worker
198
+ * configuration. If you need the foreground SSE chunk hook without
199
+ * delivery coordination (you have your own observed channel), this
200
+ * stays useful — otherwise prefer `deliver()`.
185
201
  *
186
202
  * Error semantics: any failure (network, protocol, abort, `onPayload`
187
- * callback throwing) rejects the returned Promise. `options.onError`,
188
- * when provided, is a side-channel notification (e.g. for logging or
189
- * UI flashes) and fires before the rejection it does not suppress
190
- * it. Always wrap calls in `try / await` and treat the rejection as
191
- * the canonical error path.
203
+ * callback throwing) rejects the returned Promise. `options.onError`
204
+ * fires before the rejection as a side-channel notification it does
205
+ * NOT suppress the throw. Always wrap calls in `try / await`.
192
206
  *
193
207
  * @param {Object} payload - Instant message payload.
194
208
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -198,28 +212,20 @@ var ReiClient = class {
198
212
  * @param {(error: unknown) => void} [options.onError]
199
213
  * @param {() => void} [options.onDone]
200
214
  * @param {AbortSignal} [options.signal]
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.
201
219
  * @returns {Promise<void>}
202
220
  */
203
221
  async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
204
- this._sanitizeAvatarUrl(payload);
205
- const json = JSON.stringify(payload);
206
- this._assertPayloadSize(json, "consumeInstantStream");
207
- const headers = { "Content-Type": "application/json", ...options.headers || {} };
208
- let body;
209
- if (this._instantEncryption === false) {
210
- body = json;
211
- if (this._instantClientToken) {
212
- headers["X-Client-Token"] = this._instantClientToken;
213
- }
214
- } else {
215
- const encrypted = await this._encrypt(json);
216
- headers["X-User-Id"] = this._userId;
217
- headers["X-Payload-Encrypted"] = "true";
218
- headers["X-Encryption-Version"] = "1";
219
- body = JSON.stringify(encrypted);
220
- }
221
- const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
222
- const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
222
+ this._maybeWarnLowLevel("consumeInstantStream", options);
223
+ const { url, headers, body } = await this._buildInstantRequest(
224
+ payload,
225
+ endpointPath,
226
+ { headers: options.headers, methodName: "consumeInstantStream" }
227
+ );
228
+ const res = await fetch(url, {
223
229
  method: "POST",
224
230
  headers,
225
231
  body,
@@ -230,94 +236,211 @@ var ReiClient = class {
230
236
  throw new Error(`Instant request failed: ${res.status} ${text}`);
231
237
  }
232
238
  const contentType = res.headers.get("content-type") || "";
233
- if (!contentType.includes("text/event-stream")) {
239
+ if (classifyContentType(contentType) !== "sse") {
234
240
  const text = await res.text().catch(() => "");
235
241
  throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
236
242
  }
237
243
  if (!res.body) {
238
244
  throw new Error("Response body is null");
239
245
  }
240
- const reader = res.body.getReader();
241
- const decoder = new TextDecoder();
242
- let buffer = "";
243
- let thrown;
244
246
  try {
245
- while (true) {
246
- const { done, value } = await reader.read();
247
- if (done) break;
248
- buffer += decoder.decode(value, { stream: true });
249
- const parts = buffer.split("\n\n");
250
- buffer = parts.pop() || "";
251
- for (const part of parts) {
252
- if (!part.trim()) continue;
253
- let eventName = "message";
254
- let data = "";
255
- const lines = part.split("\n");
256
- for (const line of lines) {
257
- if (line.startsWith(":")) continue;
258
- if (line.startsWith("event:")) {
259
- eventName = line.slice(6).trim();
260
- } else if (line.startsWith("data:")) {
261
- const piece = line.slice(5).trim();
262
- data = data ? `${data}
263
- ${piece}` : piece;
264
- }
265
- }
266
- if (eventName === "done") {
267
- if (options.onDone) options.onDone();
268
- return;
269
- }
270
- if (eventName === "error") {
271
- let parsedErr;
272
- try {
273
- parsedErr = JSON.parse(data);
274
- } catch {
275
- parsedErr = { code: "PARSE_ERROR", message: data };
276
- }
277
- const err = new Error(parsedErr.message || "Stream error");
278
- err.code = parsedErr.code;
279
- thrown = err;
280
- return;
281
- }
282
- if (eventName === "payload") {
283
- let parsedPayload;
284
- try {
285
- parsedPayload = JSON.parse(data);
286
- } catch {
287
- continue;
288
- }
289
- if (options.onPayload) {
290
- await options.onPayload(parsedPayload);
291
- }
292
- }
293
- }
294
- }
247
+ await this._consumeSseStream(res, { onPayload: options.onPayload });
295
248
  if (options.onDone) options.onDone();
296
249
  } catch (err) {
297
- thrown = err;
298
- } finally {
299
- if (thrown) {
300
- try {
301
- await reader.cancel(thrown);
302
- } catch {
303
- }
304
- if (options.onError) {
305
- try {
306
- options.onError(thrown);
307
- } catch {
308
- }
309
- }
250
+ if (options.onError) {
310
251
  try {
311
- reader.releaseLock();
252
+ options.onError(err);
312
253
  } catch {
313
254
  }
314
- throw thrown;
315
255
  }
256
+ throw err;
257
+ }
258
+ }
259
+ /**
260
+ * Deliver a message with an explicit delivery contract.
261
+ *
262
+ * `deliver()` is the recommended primitive for new code. It coordinates
263
+ * the foreground transport (SSE / JSON, picked automatically by
264
+ * response Content-Type) with an optional out-of-band observation
265
+ * channel that the caller supplies as a Promise — the library doesn't
266
+ * care what produces that Promise (Service Worker broadcast, IPC,
267
+ * native push handler, polling, anything). It returns a single
268
+ * `DeliveryResult` with a five-value `outcome` so you can distinguish
269
+ * `delivered` (truth-grade) from `cancelled` / `timeout` / `send-failed`
270
+ * without inferring delivery from transport rejections.
271
+ *
272
+ * Why this exists: when the server uses always-on backup Web Push
273
+ * (amsg-instant 0.9.0+ default), `sendInstant`'s HTTP 200 and
274
+ * `consumeInstantStream`'s rejection are both ambiguous w.r.t. actual
275
+ * delivery — the backup channel can still deliver after a transport
276
+ * reject, and a clean transport doesn't prove the consumer ever
277
+ * observed the message. `deliver()` resolves that ambiguity by
278
+ * making the observation channel a first-class input.
279
+ *
280
+ * @param {Object} payload - Instant message payload (same shape as `sendInstant`).
281
+ * @param {DeliverOptions} opts - Delivery contract; see typedef.
282
+ * @returns {Promise<DeliveryResult>}
283
+ */
284
+ async deliver(payload, opts) {
285
+ if (!opts || typeof opts !== "object") {
286
+ throw new TypeError("[rei-standard-amsg-client] deliver() requires an options object");
287
+ }
288
+ const {
289
+ delivery,
290
+ timeoutMs,
291
+ onChunk,
292
+ postTransportGraceMs,
293
+ signal,
294
+ headers,
295
+ authorization,
296
+ endpointPath
297
+ } = opts;
298
+ if (!delivery || typeof delivery !== "object") {
299
+ throw new TypeError("[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)");
300
+ }
301
+ if (delivery.mode !== "observed" && delivery.mode !== "transport-only") {
302
+ throw new TypeError(
303
+ '[rei-standard-amsg-client] opts.delivery.mode must be "observed" or "transport-only"'
304
+ );
305
+ }
306
+ if (delivery.mode === "observed" && !isThenable(delivery.observed)) {
307
+ throw new TypeError(
308
+ "[rei-standard-amsg-client] opts.delivery.observed must be a Promise<ObservedDeliveryReceipt>"
309
+ );
310
+ }
311
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
312
+ throw new TypeError("[rei-standard-amsg-client] opts.timeoutMs must be a positive finite number");
313
+ }
314
+ if (postTransportGraceMs !== void 0 && (typeof postTransportGraceMs !== "number" || !Number.isFinite(postTransportGraceMs) || postTransportGraceMs < 0)) {
315
+ throw new TypeError(
316
+ "[rei-standard-amsg-client] opts.postTransportGraceMs, if set, must be a non-negative finite number"
317
+ );
318
+ }
319
+ const start = Date.now();
320
+ const detail = { waitedMs: 0 };
321
+ if (signal && signal.aborted) {
322
+ detail.cancelledByCaller = true;
323
+ return { ok: false, outcome: "cancelled", detail };
324
+ }
325
+ const built = await this._buildInstantRequest(
326
+ payload,
327
+ endpointPath || "/instant",
328
+ { headers, authorization, methodName: "deliver" }
329
+ );
330
+ if (signal && signal.aborted) {
331
+ detail.cancelledByCaller = true;
332
+ detail.waitedMs = Date.now() - start;
333
+ return { ok: false, outcome: "cancelled", detail };
334
+ }
335
+ let finalized = false;
336
+ let validatedObserved = null;
337
+ let observedP = null;
338
+ if (delivery.mode === "observed") {
339
+ validatedObserved = this._waitForValidReceipt(delivery.observed);
340
+ observedP = validatedObserved.then((receipt) => ({ tag: "delivered", receipt }));
341
+ }
342
+ const wrappedOnChunk = onChunk ? async (chunk) => {
316
343
  try {
317
- reader.releaseLock();
318
- } catch {
344
+ await onChunk(chunk);
345
+ } catch (err) {
346
+ if (finalized) return;
347
+ if (detail.chunkHandlerError === void 0) detail.chunkHandlerError = err;
348
+ }
349
+ } : void 0;
350
+ const internalAbort = new AbortController();
351
+ let transportEnded = false;
352
+ let transportError;
353
+ const transportPromise = (async () => {
354
+ try {
355
+ const result = await this._runInstantTransport(built, {
356
+ signal: internalAbort.signal,
357
+ onChunk: wrappedOnChunk
358
+ });
359
+ if (finalized) return;
360
+ transportEnded = true;
361
+ if (result && result.kind === "json") detail.transportResponse = result.body;
362
+ } catch (err) {
363
+ if (finalized) return;
364
+ transportError = err;
365
+ }
366
+ })();
367
+ let timeoutId;
368
+ const timeoutP = new Promise((resolve) => {
369
+ timeoutId = setTimeout(() => resolve({ tag: "timeout" }), timeoutMs);
370
+ });
371
+ const signalListeners = [];
372
+ let cancelledP = null;
373
+ if (signal) {
374
+ cancelledP = new Promise((resolve) => {
375
+ const cancelListener = () => resolve({ tag: "cancelled" });
376
+ const abortForwarder = () => internalAbort.abort();
377
+ signal.addEventListener("abort", cancelListener, { once: true });
378
+ signal.addEventListener("abort", abortForwarder, { once: true });
379
+ signalListeners.push(cancelListener, abortForwarder);
380
+ if (signal.aborted) {
381
+ cancelListener();
382
+ abortForwarder();
383
+ }
384
+ });
385
+ }
386
+ const transportP = transportPromise.then(() => ({ tag: "transport-ended" }));
387
+ const racers = [transportP, timeoutP];
388
+ if (observedP) racers.push(observedP);
389
+ if (cancelledP) racers.push(cancelledP);
390
+ const winner = await Promise.race(racers);
391
+ const finalize = (outcome, ok, extras) => {
392
+ finalized = true;
393
+ clearTimeout(timeoutId);
394
+ internalAbort.abort();
395
+ if (signal) {
396
+ for (const l of signalListeners) signal.removeEventListener("abort", l);
397
+ }
398
+ detail.waitedMs = Date.now() - start;
399
+ if (transportEnded) detail.transportEnded = true;
400
+ if (transportError !== void 0) detail.transportError = transportError;
401
+ if (extras) Object.assign(detail, extras);
402
+ return { ok, outcome, detail };
403
+ };
404
+ const remainingBudget = () => Math.max(0, timeoutMs - (Date.now() - start));
405
+ if (winner.tag === "delivered") {
406
+ return finalize("delivered", true, { receipt: winner.receipt });
407
+ }
408
+ if (winner.tag === "cancelled") {
409
+ detail.cancelledByCaller = true;
410
+ if (validatedObserved) {
411
+ internalAbort.abort();
412
+ const cancelGrace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget()) / 2;
413
+ const lateReceipt = await this._raceObservedWithTimeout(validatedObserved, cancelGrace);
414
+ if (lateReceipt) {
415
+ return finalize("delivered", true, { receipt: lateReceipt });
416
+ }
319
417
  }
418
+ return finalize("cancelled", false);
320
419
  }
420
+ if (winner.tag === "timeout") {
421
+ return finalize("timeout", false);
422
+ }
423
+ clearTimeout(timeoutId);
424
+ if (!validatedObserved) {
425
+ if (transportError !== void 0) return finalize("send-failed", false);
426
+ return finalize("completed-unconfirmed", false, { transportEnded: true });
427
+ }
428
+ const grace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget());
429
+ const observedLateP = this._raceObservedWithTimeout(validatedObserved, grace).then((receipt) => ({ tag: "late", receipt }));
430
+ const lateRacers = [observedLateP];
431
+ if (cancelledP) lateRacers.push(cancelledP);
432
+ const lateWinner = await Promise.race(lateRacers);
433
+ if (lateWinner.tag === "cancelled") {
434
+ detail.cancelledByCaller = true;
435
+ return finalize("cancelled", false);
436
+ }
437
+ if (lateWinner.receipt) {
438
+ return finalize("delivered", true, { receipt: lateWinner.receipt });
439
+ }
440
+ if (transportError !== void 0) {
441
+ return finalize("send-failed", false);
442
+ }
443
+ return finalize("timeout", false, { transportEnded: true, observationChannelStalled: true });
321
444
  }
322
445
  /**
323
446
  * Update an existing scheduled message.
@@ -409,7 +532,7 @@ ${piece}` : piece;
409
532
  async subscribePush(vapidPublicKey, registration) {
410
533
  const subscription = await registration.pushManager.subscribe({
411
534
  userVisibleOnly: true,
412
- applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey)
535
+ applicationServerKey: base64UrlToBytes(vapidPublicKey)
413
536
  });
414
537
  return subscription;
415
538
  }
@@ -457,7 +580,7 @@ ${piece}` : piece;
457
580
  */
458
581
  _assertPayloadSize(bodyJson, methodName) {
459
582
  if (this._maxPayloadBytes == null) return;
460
- const bytes = new TextEncoder().encode(bodyJson).length;
583
+ const bytes = TEXT_ENCODER.encode(bodyJson).length;
461
584
  if (bytes > this._maxPayloadBytes) {
462
585
  throw makeLocalError(
463
586
  "PAYLOAD_TOO_LARGE_LOCAL",
@@ -466,6 +589,287 @@ ${piece}` : piece;
466
589
  );
467
590
  }
468
591
  }
592
+ // ─── Transport helpers (shared by sendInstant / consumeInstantStream / deliver) ─
593
+ /**
594
+ * Build the URL, headers, and body for an instant-endpoint POST.
595
+ * Used by `sendInstant`, `consumeInstantStream`, and `deliver`.
596
+ *
597
+ * @private
598
+ * @param {Object} payload
599
+ * @param {string} endpointPath
600
+ * @param {{ headers?: Record<string, string>, authorization?: string, methodName: string }} opts
601
+ * @returns {Promise<{ url: string, headers: Record<string, string>, body: string }>}
602
+ */
603
+ async _buildInstantRequest(payload, endpointPath, opts) {
604
+ const { headers: extraHeaders, authorization, methodName } = opts;
605
+ this._sanitizeAvatarUrl(payload);
606
+ const json = JSON.stringify(payload);
607
+ this._assertPayloadSize(json, methodName);
608
+ const headers = { "Content-Type": "application/json", ...extraHeaders || {} };
609
+ let body;
610
+ if (this._instantEncryption === false) {
611
+ body = json;
612
+ if (this._instantClientToken) headers["X-Client-Token"] = this._instantClientToken;
613
+ } else {
614
+ const encrypted = await this._encrypt(json);
615
+ headers["X-User-Id"] = this._userId;
616
+ headers["X-Payload-Encrypted"] = "true";
617
+ headers["X-Encryption-Version"] = "1";
618
+ body = JSON.stringify(encrypted);
619
+ }
620
+ if (authorization) headers["Authorization"] = authorization;
621
+ const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
622
+ const url = `${this._resolveBaseUrl("instant")}${path}`;
623
+ return { url, headers, body };
624
+ }
625
+ /**
626
+ * Run the foreground transport for `deliver()`. Takes a request pre-built
627
+ * by `_buildInstantRequest` so the caller can surface local-validation
628
+ * errors (encryption, payload-size) synchronously, instead of having
629
+ * them buried inside the post-transport grace race.
630
+ * Picks SSE or JSON based on the response Content-Type. Resolves on
631
+ * natural stream EOF / parsed JSON; throws on network / protocol / SSE
632
+ * error frame / AbortError.
633
+ *
634
+ * @private
635
+ * @param {{ url: string, headers: Record<string, string>, body: string }} built
636
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
637
+ * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
638
+ */
639
+ async _runInstantTransport(built, opts) {
640
+ const { signal, onChunk } = opts;
641
+ const { url, headers, body } = built;
642
+ const res = await fetch(url, { method: "POST", headers, body, signal });
643
+ if (!res.ok) {
644
+ const text2 = await res.text().catch(() => "");
645
+ const err = new Error(`Instant request failed: ${res.status} ${text2}`);
646
+ err.status = res.status;
647
+ throw err;
648
+ }
649
+ const contentType = res.headers.get("content-type") || "";
650
+ const kind = classifyContentType(contentType);
651
+ if (kind === "sse") {
652
+ if (!res.body) throw new Error("Response body is null");
653
+ await this._consumeSseStream(res, { onPayload: onChunk });
654
+ return { kind: "sse" };
655
+ }
656
+ if (kind === "json") {
657
+ const json = await res.json();
658
+ return { kind: "json", body: json };
659
+ }
660
+ const text = await res.text().catch(() => "");
661
+ throw new Error(`Expected text/event-stream or application/json, got ${contentType}: ${text}`);
662
+ }
663
+ /**
664
+ * Consume an SSE response body, dispatching `event: payload` frames to
665
+ * `onPayload`. Resolves on `event: done` or natural EOF. Throws on
666
+ * `event: error` frames, `onPayload` throws, or stream read errors.
667
+ *
668
+ * @private
669
+ * @param {Response} res
670
+ * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
671
+ * @returns {Promise<void>}
672
+ */
673
+ async _consumeSseStream(res, opts) {
674
+ const { onPayload } = opts;
675
+ const reader = res.body.getReader();
676
+ const decoder = new TextDecoder();
677
+ let buffer = "";
678
+ let thrown;
679
+ const processFrame = async (part) => {
680
+ if (!part.trim()) return null;
681
+ let eventName = "message";
682
+ let data = "";
683
+ const lines = part.split("\n");
684
+ for (const line of lines) {
685
+ if (line.startsWith(":")) continue;
686
+ if (line.startsWith("event:")) {
687
+ eventName = line.slice(6).trim();
688
+ } else if (line.startsWith("data:")) {
689
+ const piece = line.slice(5).trim();
690
+ data = data ? `${data}
691
+ ${piece}` : piece;
692
+ }
693
+ }
694
+ if (eventName === "done") return "done";
695
+ if (eventName === "error") {
696
+ let parsedErr;
697
+ try {
698
+ parsedErr = JSON.parse(data);
699
+ } catch {
700
+ parsedErr = { code: "PARSE_ERROR", message: data };
701
+ }
702
+ const err = new Error(parsedErr.message || "Stream error");
703
+ err.code = parsedErr.code;
704
+ throw err;
705
+ }
706
+ if (eventName === "payload") {
707
+ let parsedPayload;
708
+ try {
709
+ parsedPayload = JSON.parse(data);
710
+ } catch {
711
+ return null;
712
+ }
713
+ if (onPayload) await onPayload(parsedPayload);
714
+ }
715
+ return null;
716
+ };
717
+ try {
718
+ while (true) {
719
+ const { done, value } = await reader.read();
720
+ if (done) {
721
+ buffer += decoder.decode();
722
+ const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, "\n");
723
+ if (finalNormalized.trim()) {
724
+ await processFrame(finalNormalized);
725
+ }
726
+ return;
727
+ }
728
+ buffer += decoder.decode(value, { stream: true });
729
+ const trailingCr = buffer.endsWith("\r");
730
+ const head = trailingCr ? buffer.slice(0, -1) : buffer;
731
+ const normalized = head.replace(SSE_LINE_NORMALIZE, "\n");
732
+ const parts = normalized.split("\n\n");
733
+ buffer = (parts.pop() || "") + (trailingCr ? "\r" : "");
734
+ for (const part of parts) {
735
+ const result = await processFrame(part);
736
+ if (result === "done") return;
737
+ }
738
+ }
739
+ } catch (err) {
740
+ thrown = err;
741
+ } finally {
742
+ if (thrown) {
743
+ try {
744
+ await reader.cancel(thrown);
745
+ } catch {
746
+ }
747
+ try {
748
+ reader.releaseLock();
749
+ } catch {
750
+ }
751
+ throw thrown;
752
+ }
753
+ try {
754
+ reader.releaseLock();
755
+ } catch {
756
+ }
757
+ }
758
+ }
759
+ // ─── deliver() helpers ──────────────────────────────────────────
760
+ /**
761
+ * Wraps the caller's observed Promise so it only settles on a valid
762
+ * `ObservedDeliveryReceipt` (per RFC: at least one of messageId /
763
+ * sessionId must be a non-empty string). Invalid receipts and
764
+ * rejections leave the returned Promise pending — the race's timeout
765
+ * or abort branches take over.
766
+ *
767
+ * @private
768
+ * @param {Promise<ObservedDeliveryReceipt>} source
769
+ * @returns {Promise<ObservedDeliveryReceipt>}
770
+ */
771
+ _waitForValidReceipt(source) {
772
+ return new Promise((resolve) => {
773
+ Promise.resolve(source).then(
774
+ (receipt) => {
775
+ if (this._validateReceipt(receipt)) {
776
+ resolve(receipt);
777
+ }
778
+ },
779
+ () => {
780
+ }
781
+ );
782
+ });
783
+ }
784
+ /**
785
+ * Identity check: a receipt must be an object with at least one of
786
+ * `messageId` or `sessionId` as a non-empty string. Caller-supplied
787
+ * observation channels can produce arbitrary shapes; this gate
788
+ * prevents an empty-resolve from being interpreted as a successful
789
+ * delivery (a common shape of caller bug).
790
+ *
791
+ * @private
792
+ * @param {unknown} receipt
793
+ * @returns {boolean}
794
+ */
795
+ _validateReceipt(receipt) {
796
+ if (!receipt || typeof receipt !== "object") return false;
797
+ const hasMsgId = typeof receipt.messageId === "string" && receipt.messageId.length > 0;
798
+ const hasSessionId = typeof receipt.sessionId === "string" && receipt.sessionId.length > 0;
799
+ return hasMsgId || hasSessionId;
800
+ }
801
+ /**
802
+ * Race a Promise against a timeout. Returns the resolved value if the
803
+ * Promise wins, or `null` if the timeout fires first. Promise rejection
804
+ * is treated as "did not arrive" (same as timeout, returns `null`).
805
+ *
806
+ * @private
807
+ * @template T
808
+ * @param {Promise<T>} promise
809
+ * @param {number} ms
810
+ * @returns {Promise<T | null>}
811
+ */
812
+ _raceObservedWithTimeout(promise, ms) {
813
+ return new Promise((resolve) => {
814
+ let settled = false;
815
+ const timer = setTimeout(() => {
816
+ if (settled) return;
817
+ settled = true;
818
+ resolve(null);
819
+ }, Math.max(0, ms));
820
+ Promise.resolve(promise).then(
821
+ (value) => {
822
+ if (settled) return;
823
+ settled = true;
824
+ clearTimeout(timer);
825
+ resolve(value);
826
+ },
827
+ () => {
828
+ if (settled) return;
829
+ settled = true;
830
+ clearTimeout(timer);
831
+ resolve(null);
832
+ }
833
+ );
834
+ });
835
+ }
836
+ /**
837
+ * Post-transport grace formula. Defaults to
838
+ * `min(remainingBudget, max(5000ms, timeoutMs * 0.1))`. Caller override
839
+ * is capped by remaining budget so it can never exceed the total timeout.
840
+ *
841
+ * @private
842
+ * @param {number | undefined} override
843
+ * @param {number} totalTimeoutMs
844
+ * @param {number} remainingMs
845
+ * @returns {number}
846
+ */
847
+ _computeGrace(override, totalTimeoutMs, remainingMs) {
848
+ if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
849
+ return Math.min(override, remainingMs);
850
+ }
851
+ const defaultGrace = Math.max(5e3, Math.floor(totalTimeoutMs * 0.1));
852
+ return Math.min(defaultGrace, remainingMs);
853
+ }
854
+ /**
855
+ * One-shot dev reminder for low-level instant APIs. The warning is opt-in
856
+ * per call via `opts.expectsBackupPush === true` and fires at most once
857
+ * per ReiClient instance per method name. Default (omitted or `false`)
858
+ * is silent.
859
+ *
860
+ * @private
861
+ * @param {string} methodName
862
+ * @param {{ expectsBackupPush?: boolean }} opts
863
+ */
864
+ _maybeWarnLowLevel(methodName, opts) {
865
+ if (!opts || opts.expectsBackupPush !== true) return;
866
+ if (this._lowLevelWarned.has(methodName)) return;
867
+ this._lowLevelWarned.add(methodName);
868
+ const verdict = methodName === "sendInstant" ? "HTTP 200 \u2260 delivery confirmation" : "rejection \u2260 delivery failure";
869
+ console.warn(
870
+ `[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.`
871
+ );
872
+ }
469
873
  // ─── Crypto helpers (Web Crypto API) ────────────────────────────
470
874
  /**
471
875
  * Encrypt plaintext with AES-256-GCM.
@@ -477,7 +881,7 @@ ${piece}` : piece;
477
881
  if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
478
882
  const iv = crypto.getRandomValues(new Uint8Array(12));
479
883
  const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["encrypt"]);
480
- const encoded = new TextEncoder().encode(plaintext);
884
+ const encoded = TEXT_ENCODER.encode(plaintext);
481
885
  const cipherBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
482
886
  const cipherArr = new Uint8Array(cipherBuf);
483
887
  const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
@@ -530,15 +934,6 @@ ${piece}` : piece;
530
934
  }
531
935
  return arr;
532
936
  }
533
- /** @private */
534
- _urlBase64ToUint8Array(base64String) {
535
- const padding = "=".repeat((4 - base64String.length % 4) % 4);
536
- const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
537
- const raw = atob(base64);
538
- const arr = new Uint8Array(raw.length);
539
- for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
540
- return arr;
541
- }
542
937
  };
543
938
  function normalizeMaxPayloadBytes(value) {
544
939
  if (value === void 0 || value === null) return null;