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