@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.d.cts CHANGED
@@ -8,6 +8,12 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
8
8
  * schedule path and amsg-instant 0.1.x)
9
9
  * - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false)
10
10
  * - Push subscription management via the Push API
11
+ * - The platform-agnostic `deliver()` delivery primitive (2.5.0+) which
12
+ * coordinates the foreground transport (SSE / JSON) with an out-of-band
13
+ * observation channel (SW broadcast / IPC / native push / polling …)
14
+ * so callers get a single correct outcome instead of having to second-
15
+ * guess "did the message actually land?". See the README's `deliver()`
16
+ * section for usage and the five-outcome contract.
11
17
  *
12
18
  * Usage:
13
19
  * import { ReiClient } from '@rei-standard/amsg-client';
@@ -52,20 +58,139 @@ export { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE, buildContentPush, buildErrorPu
52
58
  * `updateMessage` always). Can be omitted only when
53
59
  * `instantEncryption: false` AND you do not call any
54
60
  * encrypted method.
55
- * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON
56
- * to amsg-instant 0.2.x. `init()` becomes a no-op.
57
- * All other methods (`scheduleMessage` etc.) keep
58
- * using AES-256-GCM regardless of this flag.
61
+ * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` / `deliver()` post
62
+ * plaintext JSON to amsg-instant 0.2.x+. `init()`
63
+ * becomes a no-op. All other methods
64
+ * (`scheduleMessage` etc.) keep using AES-256-GCM
65
+ * regardless of this flag.
59
66
  * @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by
60
- * `sendInstant()` in plaintext mode. Note: this is
61
- * a *weak* shared secret — it ships inside any
62
- * frontend bundle that uses it, so devtools can
63
- * read it. Use for casual URL-direct abuse only.
67
+ * `sendInstant()` / `deliver()` in plaintext mode.
68
+ * Note: this is a *weak* shared secret — it ships
69
+ * inside any frontend bundle that uses it, so
70
+ * devtools can read it. Use for casual URL-direct
71
+ * abuse only.
64
72
  * @property {number|null} [maxPayloadBytes=null] - Optional local UTF-8 byte cap for outgoing request
65
73
  * payloads before encryption. `null` / omitted means
66
74
  * no SDK-level request-size limit.
67
75
  */
68
76
 
77
+ /**
78
+ * @typedef {Object} ObservedDeliveryReceipt
79
+ * Receipt produced by the caller's out-of-band observation channel and
80
+ * fed back to `deliver()` via `opts.delivery.observed`. Identifies the
81
+ * delivered message so a concurrent send's signal cannot accidentally
82
+ * satisfy this call's await.
83
+ *
84
+ * Identity requirement: **at least one** of `messageId` or `sessionId`
85
+ * MUST be a non-empty string. A receipt with neither is invalid — the
86
+ * library treats it as if the observed Promise never resolved (the race
87
+ * keeps waiting). This is enforced at runtime; an empty receipt is a
88
+ * caller-side bug, not a successful delivery.
89
+ *
90
+ * @property {string} [messageId] - Stable per-message identifier.
91
+ * @property {string} [sessionId] - Session-scoped delivery identifier.
92
+ * @property {string} [channel] - Free-form origin label for diagnostics
93
+ * (e.g. 'sw', 'ipc', 'native', 'poll').
94
+ * Not validated by the library.
95
+ */
96
+
97
+ /**
98
+ * @typedef {'delivered'
99
+ * | 'completed-unconfirmed'
100
+ * | 'timeout'
101
+ * | 'cancelled'
102
+ * | 'send-failed'} DeliveryOutcome
103
+ *
104
+ * Terminal outcome of a `deliver()` call.
105
+ *
106
+ * - **delivered** — observed-mode only: the caller's observation channel
107
+ * produced a valid receipt within budget. Truth-grade success.
108
+ * - **completed-unconfirmed** — transport-only mode only: transport
109
+ * reached natural EOF cleanly but there is no truth signal to confirm
110
+ * downstream consumption. Best-effort optimistic; the caller decides
111
+ * how to interpret it.
112
+ * - **timeout** — total budget exhausted with no terminal signal; or,
113
+ * in observed mode, transport ended cleanly but the observation
114
+ * channel failed to deliver within grace (the observation pipeline
115
+ * may itself be broken — this is NOT classified as send-failed).
116
+ * `detail.observationChannelStalled` is set in the latter case.
117
+ * - **cancelled** — caller `signal.abort()` fired and no delivery was
118
+ * observed within the cancellation grace.
119
+ * - **send-failed** — transport rejected (with a captured error) AND no
120
+ * observed delivery landed within grace. Only fires when transport
121
+ * has a real error — a clean transport is never `send-failed`.
122
+ */
123
+
124
+ /**
125
+ * @typedef {Object} DeliveryResultDetail
126
+ * @property {number} waitedMs - Wall-clock ms spent in `deliver()`.
127
+ * @property {boolean} [transportEnded] - True if transport reached natural EOF (vs aborted / errored).
128
+ * @property {unknown} [transportError] - Non-null if transport rejected.
129
+ * @property {unknown} [transportResponse] - For JSON transport, the parsed response body.
130
+ * @property {unknown} [chunkHandlerError] - Non-null if `onChunk` threw at any point. Never promotes the outcome.
131
+ * @property {boolean} [cancelledByCaller] - True if caller's signal aborted before terminal outcome.
132
+ * @property {boolean} [observationChannelStalled] - True when observed-mode transport ended clean but observation never produced a valid receipt within grace.
133
+ * @property {ObservedDeliveryReceipt} [receipt] - Receipt as observed (for the `delivered` case).
134
+ */
135
+
136
+ /**
137
+ * @typedef {Object} DeliveryResult
138
+ * @property {boolean} ok - True iff outcome === 'delivered'.
139
+ * @property {DeliveryOutcome} outcome
140
+ * @property {DeliveryResultDetail} detail - Always populated; gives diagnostic context regardless of outcome.
141
+ */
142
+
143
+ /**
144
+ * @typedef {Object} ObservedDeliverySpec
145
+ * @property {'observed'} mode - Standard path with truth signal.
146
+ * @property {Promise<ObservedDeliveryReceipt>} observed - Resolves with a receipt when the message is
147
+ * observed landed via the canonical out-of-band
148
+ * channel for this platform (SW broadcast / IPC /
149
+ * native push / polling …). The library does not
150
+ * care what produces this promise — it just races
151
+ * its settlement against transport / timeout /
152
+ * abort.
153
+ */
154
+
155
+ /**
156
+ * @typedef {Object} TransportOnlyDeliverySpec
157
+ * @property {'transport-only'} mode - Non-standard / advanced. No out-of-band signal is supplied.
158
+ * In this mode `outcome:'delivered'` will NEVER be returned (no truth
159
+ * signal); a clean transport yields `completed-unconfirmed`.
160
+ */
161
+
162
+ /**
163
+ * @typedef {Object} DeliverOptions
164
+ * @property {ObservedDeliverySpec | TransportOnlyDeliverySpec} delivery - Discriminated union — caller must
165
+ * explicitly pick a mode. There is no implicit default to discourage "I forgot to wire up observation".
166
+ * @property {number} timeoutMs - Total budget in ms (transport + post-transport grace).
167
+ * @property {(payload: unknown) => Promise<void> | void} [onChunk] - Optional inline chunk handler for the
168
+ * foreground SSE transport. If omitted, transport still runs (for delivery effects) but no per-chunk UI
169
+ * hook fires. Throws are captured into `detail.chunkHandlerError` and do NOT promote the outcome to
170
+ * `'send-failed'` — UI hook failures are caller-bug-shaped, not transport failures. Not invoked for the
171
+ * JSON transport.
172
+ * @property {number} [postTransportGraceMs] - After transport settles (clean OR error), max wait for
173
+ * the observation channel before declaring failure. Default = `min(remainingBudget, max(5000, timeoutMs * 0.1))`.
174
+ * The 5s floor protects the grace from being slashed to ~0 by very short timeouts; the 10% scale gives
175
+ * sensible grace across 30s / 300s / multi-minute budgets. **Cancel-path note**: when the caller's
176
+ * `signal` fires before any other terminal event, the late-receipt window after the abort is
177
+ * `grace / 2` (the other half is reserved for cleanup). Tune this knob with that halving in mind if
178
+ * you care about late-arriving receipts after user cancellation.
179
+ * @property {AbortSignal} [signal] - Cooperative cancellation. Pre-flight: if already aborted
180
+ * at entry, returns `cancelled` synchronously without dispatching transport. Listeners added to this
181
+ * signal are removed on every terminal outcome, so a long-lived signal reused across many calls does
182
+ * not accumulate stale handlers.
183
+ * @property {Record<string, string>} [headers] - Extra request headers forwarded into the underlying fetch.
184
+ * Caller-supplied keys are merged AFTER `Content-Type`/encryption headers, so they can override
185
+ * `Content-Type` but NOT `X-User-Id` / `X-Payload-Encrypted` / `X-Encryption-Version` / `X-Client-Token`
186
+ * / `Authorization` (use the `authorization` option for the last one).
187
+ * @property {string} [authorization] - Optional `Authorization` header forwarded as-is. Mirrors
188
+ * `sendInstant`'s `opts.authorization` so migrations from `sendInstant({authorization: ...})` to
189
+ * `deliver()` don't silently drop the header.
190
+ * @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass
191
+ * `'/continue'` for tool-result resume on amsg-instant 0.9.0+.
192
+ */
193
+
69
194
  /**
70
195
  * Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors
71
196
  * `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side
@@ -81,6 +206,37 @@ function makeLocalError(code, message, details) {
81
206
  return err;
82
207
  }
83
208
 
209
+ function isThenable(value) {
210
+ return !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function';
211
+ }
212
+
213
+ /**
214
+ * Per SSE spec, a single line terminator is `\r\n`, `\n`, or `\r`;
215
+ * an event ends with TWO consecutive terminators. We normalize the
216
+ * buffer to LF-only before framing so the split logic stays a simple
217
+ * `'\n\n'` (a `{2}` quantifier on alternations backtracks and would
218
+ * mis-treat a lone `\r\n` as two terminators).
219
+ */
220
+ const SSE_LINE_NORMALIZE = /\r\n?/g;
221
+
222
+ /**
223
+ * Classify a Content-Type value as 'sse' (text/event-stream), 'json'
224
+ * (application/json + structured-suffix variants like application/problem+json,
225
+ * application/vnd.api+json), or 'unknown'.
226
+ *
227
+ * Properly parses the media-type: splits on `;` to drop parameters, trims,
228
+ * lowercases, then exact-matches. A naïve substring search over the whole
229
+ * header would mis-classify e.g. `application/json; note=text/event-stream`
230
+ * as SSE, or `text/plain; x=application/json` as JSON.
231
+ */
232
+ function classifyContentType(contentType) {
233
+ const main = (contentType || '').split(';')[0].trim().toLowerCase();
234
+ if (main === 'text/event-stream') return 'sse';
235
+ if (main === 'application/json') return 'json';
236
+ if (/^application\/[\w.+-]+\+json$/.test(main)) return 'json';
237
+ return 'unknown';
238
+ }
239
+
84
240
  class ReiClient {
85
241
  /**
86
242
  * @param {ReiClientConfig} config
@@ -118,6 +274,12 @@ class ReiClient {
118
274
  : '';
119
275
  /** @private */
120
276
  this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes);
277
+ /**
278
+ * Per-instance latch (set of method names already warned). The
279
+ * low-level dev warning fires at most once per ReiClient per method.
280
+ * @private @type {Set<string>}
281
+ */
282
+ this._lowLevelWarned = new Set();
121
283
  }
122
284
 
123
285
  /**
@@ -138,10 +300,11 @@ class ReiClient {
138
300
  * Must be called before any encrypted request.
139
301
  *
140
302
  * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
141
- * `sendInstant()` does not need a userKey. Note that if you also intend to
142
- * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
143
- * use AES-256-GCM), you must construct with `instantEncryption: true`
144
- * (the default) — those methods will throw "Not initialised" otherwise.
303
+ * `sendInstant()` / `deliver()` do not need a userKey. Note that if you
304
+ * also intend to call `scheduleMessage` / `listMessages` / `updateMessage`
305
+ * (which always use AES-256-GCM), you must construct with
306
+ * `instantEncryption: true` (the default) — those methods will throw
307
+ * "Not initialised" otherwise.
145
308
  */
146
309
  async init() {
147
310
  if (this._instantEncryption === false) {
@@ -169,11 +332,11 @@ class ReiClient {
169
332
  /**
170
333
  * Schedule a message.
171
334
  *
172
- * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
173
- * That routes through `@rei-standard/amsg-instant` (stateless, no DB
174
- * round-trip) rather than `amsg-server`'s schedule-message endpoint.
175
- * This method still works for instant via amsg-server for backward
176
- * compatibility — see CHANGELOG / README for details.
335
+ * Note: For `messageType: 'instant'`, prefer `deliver()` (2.5.0+) or
336
+ * `sendInstant()`. Both route through `@rei-standard/amsg-instant`
337
+ * (stateless, no DB round-trip) rather than `amsg-server`'s schedule-
338
+ * message endpoint. This method still works for instant via amsg-server
339
+ * for backward compatibility — see CHANGELOG / README for details.
177
340
  *
178
341
  * The payload is automatically encrypted before transmission.
179
342
  *
@@ -206,12 +369,19 @@ class ReiClient {
206
369
  }
207
370
 
208
371
  /**
209
- * Send a one-shot instant message via `@rei-standard/amsg-instant`.
372
+ * **Low-level JSON dispatcher.** Use `deliver()` for new code — it
373
+ * gives you a correct `send-failed` vs `delivered` verdict by
374
+ * coordinating transport with an out-of-band observation channel.
210
375
  *
211
- * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
212
- * - No DB round-trip on the server side (stateless)
213
- * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
214
- * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
376
+ * Posts an instant message via `@rei-standard/amsg-instant` and
377
+ * returns whatever the worker returns. **HTTP 200 ≠ delivery
378
+ * confirmation** when amsg-instant is configured with backup Web
379
+ * Push (default in 0.9.0+): the dispatch succeeded but the message
380
+ * may still land via the backup channel even if this call rejected,
381
+ * and a 200 here does not guarantee the consumer ever saw it. If
382
+ * you only care about the transport response (no delivery
383
+ * coordination needed), this stays useful — otherwise prefer
384
+ * `deliver()`.
215
385
  *
216
386
  * Two transport modes (chosen by constructor `instantEncryption`):
217
387
  *
@@ -221,65 +391,53 @@ class ReiClient {
221
391
  * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
222
392
  *
223
393
  * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
224
- * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
394
+ * JSON. Targets amsg-instant 0.2.x+. Sends `X-Client-Token` if
225
395
  * `instantClientToken` was configured.
226
396
  *
227
397
  * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
228
398
  *
229
- * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the
230
- * client soft-strips it on the payload and emits a `console.warn` — the
231
- * push still ships, just without an icon. If `maxPayloadBytes` is
232
- * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`.
233
- *
234
399
  * @param {Object} payload - Instant message payload.
235
400
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
236
- * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
401
+ * @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts]
402
+ * - `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).
237
408
  * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
238
409
  */
239
410
  async sendInstant(payload, endpointPath = '/instant', opts = {}) {
240
- this._sanitizeAvatarUrl(payload);
241
- const json = JSON.stringify(payload);
242
- this._assertPayloadSize(json, 'sendInstant');
243
-
244
- const headers = { 'Content-Type': 'application/json' };
245
- let body;
246
-
247
- if (this._instantEncryption === false) {
248
- body = json;
249
- if (this._instantClientToken) {
250
- headers['X-Client-Token'] = this._instantClientToken;
251
- }
252
- } else {
253
- const encrypted = await this._encrypt(json);
254
- headers['X-User-Id'] = this._userId;
255
- headers['X-Payload-Encrypted'] = 'true';
256
- headers['X-Encryption-Version'] = '1';
257
- body = JSON.stringify(encrypted);
258
- }
259
-
260
- if (opts.authorization) {
261
- headers['Authorization'] = opts.authorization;
262
- }
411
+ this._maybeWarnLowLevel('sendInstant', opts);
263
412
 
264
- const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
265
- const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
266
- method: 'POST',
267
- headers,
268
- body
269
- });
413
+ const { url, headers, body } = await this._buildInstantRequest(
414
+ payload,
415
+ endpointPath,
416
+ { authorization: opts.authorization, methodName: 'sendInstant' }
417
+ );
270
418
 
419
+ const res = await fetch(url, { method: 'POST', headers, body });
271
420
  return res.json();
272
421
  }
273
422
 
274
423
  /**
275
- * Consume an instant SSE stream.
424
+ * **Low-level SSE consumer.** Use `deliver()` for new code — it gives
425
+ * you a correct `send-failed` vs `delivered` verdict by coordinating
426
+ * transport with an out-of-band observation channel.
427
+ *
428
+ * **Rejection ≠ delivery failure** when amsg-instant is configured
429
+ * with backup Web Push (default in 0.9.0+): SSE may reject for many
430
+ * unrelated reasons (iOS background tab killed fetch, network blip,
431
+ * worker 5xx) while the backup push still lands the message. Treating
432
+ * the rejection as the canonical error path is wrong for that worker
433
+ * configuration. If you need the foreground SSE chunk hook without
434
+ * delivery coordination (you have your own observed channel), this
435
+ * stays useful — otherwise prefer `deliver()`.
276
436
  *
277
437
  * Error semantics: any failure (network, protocol, abort, `onPayload`
278
- * callback throwing) rejects the returned Promise. `options.onError`,
279
- * when provided, is a side-channel notification (e.g. for logging or
280
- * UI flashes) and fires before the rejection it does not suppress
281
- * it. Always wrap calls in `try / await` and treat the rejection as
282
- * the canonical error path.
438
+ * callback throwing) rejects the returned Promise. `options.onError`
439
+ * fires before the rejection as a side-channel notification it does
440
+ * NOT suppress the throw. Always wrap calls in `try / await`.
283
441
  *
284
442
  * @param {Object} payload - Instant message payload.
285
443
  * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
@@ -289,31 +447,22 @@ class ReiClient {
289
447
  * @param {(error: unknown) => void} [options.onError]
290
448
  * @param {() => void} [options.onDone]
291
449
  * @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.
292
454
  * @returns {Promise<void>}
293
455
  */
294
456
  async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
295
- this._sanitizeAvatarUrl(payload);
296
- const json = JSON.stringify(payload);
297
- this._assertPayloadSize(json, 'consumeInstantStream');
298
-
299
- const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
300
- let body;
457
+ this._maybeWarnLowLevel('consumeInstantStream', options);
301
458
 
302
- if (this._instantEncryption === false) {
303
- body = json;
304
- if (this._instantClientToken) {
305
- headers['X-Client-Token'] = this._instantClientToken;
306
- }
307
- } else {
308
- const encrypted = await this._encrypt(json);
309
- headers['X-User-Id'] = this._userId;
310
- headers['X-Payload-Encrypted'] = 'true';
311
- headers['X-Encryption-Version'] = '1';
312
- body = JSON.stringify(encrypted);
313
- }
459
+ const { url, headers, body } = await this._buildInstantRequest(
460
+ payload,
461
+ endpointPath,
462
+ { headers: options.headers, methodName: 'consumeInstantStream' }
463
+ );
314
464
 
315
- const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
316
- const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
465
+ const res = await fetch(url, {
317
466
  method: 'POST',
318
467
  headers,
319
468
  body,
@@ -326,7 +475,7 @@ class ReiClient {
326
475
  }
327
476
 
328
477
  const contentType = res.headers.get('content-type') || '';
329
- if (!contentType.includes('text/event-stream')) {
478
+ if (classifyContentType(contentType) !== 'sse') {
330
479
  const text = await res.text().catch(() => '');
331
480
  throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
332
481
  }
@@ -335,89 +484,276 @@ class ReiClient {
335
484
  throw new Error('Response body is null');
336
485
  }
337
486
 
338
- const reader = res.body.getReader();
339
- const decoder = new TextDecoder();
340
- let buffer = '';
341
- let thrown;
342
-
343
487
  try {
344
- while (true) {
345
- const { done, value } = await reader.read();
346
- if (done) break;
488
+ await this._consumeSseStream(res, { onPayload: options.onPayload });
489
+ if (options.onDone) options.onDone();
490
+ } catch (err) {
491
+ if (options.onError) {
492
+ try { options.onError(err); } catch { /* observer can't break the throw */ }
493
+ }
494
+ throw err;
495
+ }
496
+ }
347
497
 
348
- buffer += decoder.decode(value, { stream: true });
349
- const parts = buffer.split('\n\n');
350
- buffer = parts.pop() || ''; // last part may be incomplete
498
+ /**
499
+ * Deliver a message with an explicit delivery contract.
500
+ *
501
+ * `deliver()` is the recommended primitive for new code. It coordinates
502
+ * the foreground transport (SSE / JSON, picked automatically by
503
+ * response Content-Type) with an optional out-of-band observation
504
+ * channel that the caller supplies as a Promise — the library doesn't
505
+ * care what produces that Promise (Service Worker broadcast, IPC,
506
+ * native push handler, polling, anything). It returns a single
507
+ * `DeliveryResult` with a five-value `outcome` so you can distinguish
508
+ * `delivered` (truth-grade) from `cancelled` / `timeout` / `send-failed`
509
+ * without inferring delivery from transport rejections.
510
+ *
511
+ * Why this exists: when the server uses always-on backup Web Push
512
+ * (amsg-instant 0.9.0+ default), `sendInstant`'s HTTP 200 and
513
+ * `consumeInstantStream`'s rejection are both ambiguous w.r.t. actual
514
+ * delivery — the backup channel can still deliver after a transport
515
+ * reject, and a clean transport doesn't prove the consumer ever
516
+ * observed the message. `deliver()` resolves that ambiguity by
517
+ * making the observation channel a first-class input.
518
+ *
519
+ * @param {Object} payload - Instant message payload (same shape as `sendInstant`).
520
+ * @param {DeliverOptions} opts - Delivery contract; see typedef.
521
+ * @returns {Promise<DeliveryResult>}
522
+ */
523
+ async deliver(payload, opts) {
524
+ if (!opts || typeof opts !== 'object') {
525
+ throw new TypeError('[rei-standard-amsg-client] deliver() requires an options object');
526
+ }
527
+ const {
528
+ delivery, timeoutMs, onChunk, postTransportGraceMs,
529
+ signal, headers, authorization, endpointPath,
530
+ } = opts;
351
531
 
352
- for (const part of parts) {
353
- if (!part.trim()) continue;
354
-
355
- let eventName = 'message';
356
- // Per SSE spec multiple `data:` lines in one event concatenate
357
- // with `\n`. Our own server always emits a single data line,
358
- // but `consumeInstantStream` is a general-purpose consumer.
359
- let data = '';
360
-
361
- const lines = part.split('\n');
362
- for (const line of lines) {
363
- if (line.startsWith(':')) continue; // keepalive comment
364
- if (line.startsWith('event:')) {
365
- eventName = line.slice(6).trim();
366
- } else if (line.startsWith('data:')) {
367
- const piece = line.slice(5).trim();
368
- data = data ? `${data}\n${piece}` : piece;
369
- }
370
- }
532
+ if (!delivery || typeof delivery !== 'object') {
533
+ throw new TypeError('[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)');
534
+ }
535
+ if (delivery.mode !== 'observed' && delivery.mode !== 'transport-only') {
536
+ throw new TypeError(
537
+ '[rei-standard-amsg-client] opts.delivery.mode must be "observed" or "transport-only"'
538
+ );
539
+ }
540
+ if (delivery.mode === 'observed' && !isThenable(delivery.observed)) {
541
+ throw new TypeError(
542
+ '[rei-standard-amsg-client] opts.delivery.observed must be a Promise<ObservedDeliveryReceipt>'
543
+ );
544
+ }
545
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
546
+ throw new TypeError('[rei-standard-amsg-client] opts.timeoutMs must be a positive finite number');
547
+ }
548
+ if (
549
+ postTransportGraceMs !== undefined &&
550
+ (typeof postTransportGraceMs !== 'number' || !Number.isFinite(postTransportGraceMs) || postTransportGraceMs < 0)
551
+ ) {
552
+ throw new TypeError(
553
+ '[rei-standard-amsg-client] opts.postTransportGraceMs, if set, must be a non-negative finite number'
554
+ );
555
+ }
371
556
 
372
- if (eventName === 'done') {
373
- if (options.onDone) options.onDone();
374
- return;
375
- }
557
+ const start = Date.now();
558
+ const detail = { waitedMs: 0 };
376
559
 
377
- if (eventName === 'error') {
378
- let parsedErr;
379
- try {
380
- parsedErr = JSON.parse(data);
381
- } catch {
382
- parsedErr = { code: 'PARSE_ERROR', message: data };
383
- }
384
- const err = new Error(parsedErr.message || 'Stream error');
385
- err.code = parsedErr.code;
386
- thrown = err;
387
- return; // exit loop, finally re-throws
388
- }
560
+ // Pre-flight: don't dispatch transport if signal is already aborted.
561
+ if (signal && signal.aborted) {
562
+ detail.cancelledByCaller = true;
563
+ return { ok: false, outcome: 'cancelled', detail };
564
+ }
389
565
 
390
- if (eventName === 'payload') {
391
- let parsedPayload;
392
- try {
393
- parsedPayload = JSON.parse(data);
394
- } catch {
395
- continue;
396
- }
397
- if (options.onPayload) {
398
- await options.onPayload(parsedPayload);
399
- }
400
- }
566
+ // Build the request synchronously so local-validation errors
567
+ // (PAYLOAD_TOO_LARGE_LOCAL, encryption "Not initialised") surface
568
+ // as thrown Errors from deliver() itself — not buried inside a
569
+ // post-grace `send-failed` detail.transportError.
570
+ const built = await this._buildInstantRequest(
571
+ payload,
572
+ endpointPath || '/instant',
573
+ { headers, authorization, methodName: 'deliver' }
574
+ );
575
+
576
+ // Second abort check: `_buildInstantRequest` is `await`ed
577
+ // (encrypted path does Web Crypto), so the caller's signal may
578
+ // have aborted while we were building. Catching it here keeps
579
+ // the "aborted before dispatch ⇒ no fetch" contract honest.
580
+ if (signal && signal.aborted) {
581
+ detail.cancelledByCaller = true;
582
+ detail.waitedMs = Date.now() - start;
583
+ return { ok: false, outcome: 'cancelled', detail };
584
+ }
585
+
586
+ // ---- Once `finalized` flips, no late closure may mutate caller-visible
587
+ // `detail` — caller holds it by reference and we don't want a post-return
588
+ // write to flip transportResponse / chunkHandlerError underneath them.
589
+ let finalized = false;
590
+
591
+ // ---- Observation promise (observed mode only — no NEVER_SETTLES
592
+ // retention in transport-only). ----
593
+ let validatedObserved = null;
594
+ let observedP = null;
595
+ if (delivery.mode === 'observed') {
596
+ validatedObserved = this._waitForValidReceipt(delivery.observed);
597
+ observedP = validatedObserved.then((receipt) => ({ tag: 'delivered', receipt }));
598
+ }
599
+
600
+ // ---- onChunk wrapping (errors captured, never propagated; gated on
601
+ // `finalized` so a late throw can't mutate caller-held detail). ----
602
+ const wrappedOnChunk = onChunk
603
+ ? async (chunk) => {
604
+ try { await onChunk(chunk); }
605
+ catch (err) {
606
+ if (finalized) return;
607
+ if (detail.chunkHandlerError === undefined) detail.chunkHandlerError = err;
401
608
  }
402
609
  }
610
+ : undefined;
611
+
612
+ // ---- Transport plumbing ----
613
+ const internalAbort = new AbortController();
614
+ let transportEnded = false;
615
+ let transportError;
616
+
617
+ const transportPromise = (async () => {
618
+ try {
619
+ const result = await this._runInstantTransport(built, {
620
+ signal: internalAbort.signal,
621
+ onChunk: wrappedOnChunk,
622
+ });
623
+ if (finalized) return;
624
+ transportEnded = true;
625
+ if (result && result.kind === 'json') detail.transportResponse = result.body;
626
+ } catch (err) {
627
+ if (finalized) return;
628
+ transportError = err;
629
+ }
630
+ })();
403
631
 
404
- // Stream ended without `event: done` — treat EOF as done.
405
- if (options.onDone) options.onDone();
406
- } catch (err) {
407
- thrown = err;
408
- } finally {
409
- // Always notify onError (side-channel) and always throw — callers
410
- // rely on Promise rejection as the canonical failure signal.
411
- if (thrown) {
412
- try { await reader.cancel(thrown); } catch { /* already cancelled */ }
413
- if (options.onError) {
414
- try { options.onError(thrown); } catch { /* observer can't break the throw */ }
632
+ // ---- Race plumbing ----
633
+ let timeoutId;
634
+ const timeoutP = new Promise((resolve) => {
635
+ timeoutId = setTimeout(() => resolve({ tag: 'timeout' }), timeoutMs);
636
+ });
637
+
638
+ // Track signal listeners so we can remove them on every terminal
639
+ // outcome — otherwise a long-lived signal reused across many calls
640
+ // accumulates {once:true} handlers that retain our closures.
641
+ const signalListeners = [];
642
+ let cancelledP = null;
643
+ if (signal) {
644
+ cancelledP = new Promise((resolve) => {
645
+ const cancelListener = () => resolve({ tag: 'cancelled' });
646
+ const abortForwarder = () => internalAbort.abort();
647
+ signal.addEventListener('abort', cancelListener, { once: true });
648
+ signal.addEventListener('abort', abortForwarder, { once: true });
649
+ signalListeners.push(cancelListener, abortForwarder);
650
+ // DOM spec: addEventListener('abort', ...) on an already-aborted
651
+ // signal does NOT fire. Pre-flight covered "aborted before entry",
652
+ // but the microtask window between that check and these adds is
653
+ // observable — fire the listeners synchronously if we missed it.
654
+ if (signal.aborted) {
655
+ cancelListener();
656
+ abortForwarder();
415
657
  }
416
- try { reader.releaseLock(); } catch { /* already released */ }
417
- throw thrown;
658
+ });
659
+ }
660
+
661
+ const transportP = transportPromise.then(() => ({ tag: 'transport-ended' }));
662
+
663
+ // Build the race conditionally — only include racers that can actually
664
+ // settle, so we don't attach handlers to a forever-pending Promise and
665
+ // leak reactions across many calls.
666
+ const racers = [transportP, timeoutP];
667
+ if (observedP) racers.push(observedP);
668
+ if (cancelledP) racers.push(cancelledP);
669
+
670
+ const winner = await Promise.race(racers);
671
+
672
+ // ---- Terminal-state finalization ----
673
+ // `finalize` is the single exit gate. Sets `finalized=true` before
674
+ // any return so the still-running transport IIFE / onChunk callback
675
+ // can't mutate the caller-held detail; clears the main-timeout timer;
676
+ // aborts the transport for cleanup; removes the caller-signal
677
+ // listeners we attached; stamps waitedMs + transport status onto
678
+ // detail; returns.
679
+ const finalize = (outcome, ok, extras) => {
680
+ finalized = true;
681
+ clearTimeout(timeoutId);
682
+ internalAbort.abort();
683
+ if (signal) {
684
+ for (const l of signalListeners) signal.removeEventListener('abort', l);
418
685
  }
419
- try { reader.releaseLock(); } catch { /* already released */ }
686
+ detail.waitedMs = Date.now() - start;
687
+ if (transportEnded) detail.transportEnded = true;
688
+ if (transportError !== undefined) detail.transportError = transportError;
689
+ if (extras) Object.assign(detail, extras);
690
+ return { ok, outcome, detail };
691
+ };
692
+
693
+ const remainingBudget = () => Math.max(0, timeoutMs - (Date.now() - start));
694
+
695
+ // ── Winner: delivered ────────────────────────────────────────
696
+ if (winner.tag === 'delivered') {
697
+ return finalize('delivered', true, { receipt: winner.receipt });
698
+ }
699
+
700
+ // ── Winner: cancelled ────────────────────────────────────────
701
+ if (winner.tag === 'cancelled') {
702
+ // Cancel-grace window: late receipt may still arrive in the
703
+ // microtask after abort. Only meaningful in observed mode — no
704
+ // observation channel exists in transport-only, so the wait is
705
+ // pure dead time and we short-circuit.
706
+ detail.cancelledByCaller = true;
707
+ if (validatedObserved) {
708
+ internalAbort.abort();
709
+ const cancelGrace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget()) / 2;
710
+ const lateReceipt = await this._raceObservedWithTimeout(validatedObserved, cancelGrace);
711
+ if (lateReceipt) {
712
+ return finalize('delivered', true, { receipt: lateReceipt });
713
+ }
714
+ }
715
+ return finalize('cancelled', false);
420
716
  }
717
+
718
+ // ── Winner: timeout ──────────────────────────────────────────
719
+ if (winner.tag === 'timeout') {
720
+ return finalize('timeout', false);
721
+ }
722
+
723
+ // ── Winner: transport-ended ──────────────────────────────────
724
+ clearTimeout(timeoutId);
725
+
726
+ // Transport-only: no observation channel can ever settle, so the
727
+ // grace wait is pure dead time. Decide the outcome from the
728
+ // transport result immediately.
729
+ if (!validatedObserved) {
730
+ if (transportError !== undefined) return finalize('send-failed', false);
731
+ return finalize('completed-unconfirmed', false, { transportEnded: true });
732
+ }
733
+
734
+ // Observed mode: wait up to `grace` for a late receipt, but keep the
735
+ // caller's cancel signal in the race — otherwise an abort during
736
+ // grace is silently downgraded to timeout / send-failed.
737
+ const grace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget());
738
+ const observedLateP = this._raceObservedWithTimeout(validatedObserved, grace)
739
+ .then((receipt) => ({ tag: 'late', receipt }));
740
+ const lateRacers = [observedLateP];
741
+ if (cancelledP) lateRacers.push(cancelledP);
742
+ const lateWinner = await Promise.race(lateRacers);
743
+
744
+ if (lateWinner.tag === 'cancelled') {
745
+ detail.cancelledByCaller = true;
746
+ return finalize('cancelled', false);
747
+ }
748
+ if (lateWinner.receipt) {
749
+ return finalize('delivered', true, { receipt: lateWinner.receipt });
750
+ }
751
+ if (transportError !== undefined) {
752
+ // Case A: transport had captured error → real send failure
753
+ return finalize('send-failed', false);
754
+ }
755
+ // Case C: observed mode + clean transport + missing observation = stalled
756
+ return finalize('timeout', false, { transportEnded: true, observationChannelStalled: true });
421
757
  }
422
758
 
423
759
  /**
@@ -585,6 +921,310 @@ class ReiClient {
585
921
  }
586
922
  }
587
923
 
924
+ // ─── Transport helpers (shared by sendInstant / consumeInstantStream / deliver) ─
925
+
926
+ /**
927
+ * Build the URL, headers, and body for an instant-endpoint POST.
928
+ * Used by `sendInstant`, `consumeInstantStream`, and `deliver`.
929
+ *
930
+ * @private
931
+ * @param {Object} payload
932
+ * @param {string} endpointPath
933
+ * @param {{ headers?: Record<string, string>, authorization?: string, methodName: string }} opts
934
+ * @returns {Promise<{ url: string, headers: Record<string, string>, body: string }>}
935
+ */
936
+ async _buildInstantRequest(payload, endpointPath, opts) {
937
+ const { headers: extraHeaders, authorization, methodName } = opts;
938
+ this._sanitizeAvatarUrl(payload);
939
+ const json = JSON.stringify(payload);
940
+ this._assertPayloadSize(json, methodName);
941
+
942
+ const headers = { 'Content-Type': 'application/json', ...(extraHeaders || {}) };
943
+ let body;
944
+
945
+ if (this._instantEncryption === false) {
946
+ body = json;
947
+ if (this._instantClientToken) headers['X-Client-Token'] = this._instantClientToken;
948
+ } else {
949
+ const encrypted = await this._encrypt(json);
950
+ headers['X-User-Id'] = this._userId;
951
+ headers['X-Payload-Encrypted'] = 'true';
952
+ headers['X-Encryption-Version'] = '1';
953
+ body = JSON.stringify(encrypted);
954
+ }
955
+
956
+ if (authorization) headers['Authorization'] = authorization;
957
+
958
+ const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
959
+ const url = `${this._resolveBaseUrl('instant')}${path}`;
960
+ return { url, headers, body };
961
+ }
962
+
963
+ /**
964
+ * Run the foreground transport for `deliver()`. Takes a request pre-built
965
+ * by `_buildInstantRequest` so the caller can surface local-validation
966
+ * errors (encryption, payload-size) synchronously, instead of having
967
+ * them buried inside the post-transport grace race.
968
+ * Picks SSE or JSON based on the response Content-Type. Resolves on
969
+ * natural stream EOF / parsed JSON; throws on network / protocol / SSE
970
+ * error frame / AbortError.
971
+ *
972
+ * @private
973
+ * @param {{ url: string, headers: Record<string, string>, body: string }} built
974
+ * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise<void> | void }} opts
975
+ * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>}
976
+ */
977
+ async _runInstantTransport(built, opts) {
978
+ const { signal, onChunk } = opts;
979
+ const { url, headers, body } = built;
980
+
981
+ const res = await fetch(url, { method: 'POST', headers, body, signal });
982
+
983
+ if (!res.ok) {
984
+ const text = await res.text().catch(() => '');
985
+ const err = new Error(`Instant request failed: ${res.status} ${text}`);
986
+ err.status = res.status;
987
+ throw err;
988
+ }
989
+
990
+ const contentType = res.headers.get('content-type') || '';
991
+ const kind = classifyContentType(contentType);
992
+ if (kind === 'sse') {
993
+ if (!res.body) throw new Error('Response body is null');
994
+ await this._consumeSseStream(res, { onPayload: onChunk });
995
+ return { kind: 'sse' };
996
+ }
997
+ if (kind === 'json') {
998
+ const json = await res.json();
999
+ return { kind: 'json', body: json };
1000
+ }
1001
+ const text = await res.text().catch(() => '');
1002
+ throw new Error(`Expected text/event-stream or application/json, got ${contentType}: ${text}`);
1003
+ }
1004
+
1005
+ /**
1006
+ * Consume an SSE response body, dispatching `event: payload` frames to
1007
+ * `onPayload`. Resolves on `event: done` or natural EOF. Throws on
1008
+ * `event: error` frames, `onPayload` throws, or stream read errors.
1009
+ *
1010
+ * @private
1011
+ * @param {Response} res
1012
+ * @param {{ onPayload?: (p: unknown) => Promise<void> | void }} opts
1013
+ * @returns {Promise<void>}
1014
+ */
1015
+ async _consumeSseStream(res, opts) {
1016
+ const { onPayload } = opts;
1017
+ const reader = res.body.getReader();
1018
+ const decoder = new TextDecoder();
1019
+ let buffer = '';
1020
+ let thrown;
1021
+
1022
+ // Parse one SSE frame body (lines between two terminators). Returns
1023
+ // `'done'` if the frame signals end-of-stream so the caller can
1024
+ // unwind without consuming further frames. Throws on `event: error`.
1025
+ // Assumes the input has already been line-normalized to LF.
1026
+ const processFrame = async (part) => {
1027
+ if (!part.trim()) return null;
1028
+ let eventName = 'message';
1029
+ // Per SSE spec multiple `data:` lines in one event concatenate
1030
+ // with `\n`. Our own server always emits a single data line,
1031
+ // but `_consumeSseStream` is a general-purpose consumer.
1032
+ let data = '';
1033
+ const lines = part.split('\n');
1034
+ for (const line of lines) {
1035
+ if (line.startsWith(':')) continue; // keepalive comment
1036
+ if (line.startsWith('event:')) {
1037
+ eventName = line.slice(6).trim();
1038
+ } else if (line.startsWith('data:')) {
1039
+ const piece = line.slice(5).trim();
1040
+ data = data ? `${data}\n${piece}` : piece;
1041
+ }
1042
+ }
1043
+ if (eventName === 'done') return 'done';
1044
+ if (eventName === 'error') {
1045
+ let parsedErr;
1046
+ try { parsedErr = JSON.parse(data); }
1047
+ catch { parsedErr = { code: 'PARSE_ERROR', message: data }; }
1048
+ const err = new Error(parsedErr.message || 'Stream error');
1049
+ err.code = parsedErr.code;
1050
+ throw err;
1051
+ }
1052
+ if (eventName === 'payload') {
1053
+ let parsedPayload;
1054
+ try { parsedPayload = JSON.parse(data); }
1055
+ catch { return null; }
1056
+ if (onPayload) await onPayload(parsedPayload);
1057
+ }
1058
+ return null;
1059
+ };
1060
+
1061
+ try {
1062
+ while (true) {
1063
+ const { done, value } = await reader.read();
1064
+ if (done) {
1065
+ // Flush any tail bytes the decoder held back (partial UTF-8
1066
+ // sequences split across the final chunk boundary).
1067
+ buffer += decoder.decode();
1068
+ // Some servers close without a final blank-line terminator;
1069
+ // the trailing buffer may still contain a complete frame body.
1070
+ const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, '\n');
1071
+ if (finalNormalized.trim()) {
1072
+ await processFrame(finalNormalized);
1073
+ }
1074
+ return;
1075
+ }
1076
+ buffer += decoder.decode(value, { stream: true });
1077
+ // A `\r` at the very end of the buffer is ambiguous — it might be
1078
+ // a standalone CR line terminator, OR the first byte of a CRLF
1079
+ // whose `\n` is in the next chunk. Defer normalization of that one
1080
+ // trailing byte until either more data arrives or the stream ends.
1081
+ const trailingCr = buffer.endsWith('\r');
1082
+ const head = trailingCr ? buffer.slice(0, -1) : buffer;
1083
+ const normalized = head.replace(SSE_LINE_NORMALIZE, '\n');
1084
+ const parts = normalized.split('\n\n');
1085
+ // Re-attach the deferred `\r` to whatever remains incomplete.
1086
+ buffer = (parts.pop() || '') + (trailingCr ? '\r' : '');
1087
+ for (const part of parts) {
1088
+ const result = await processFrame(part);
1089
+ if (result === 'done') return;
1090
+ }
1091
+ }
1092
+ } catch (err) {
1093
+ thrown = err;
1094
+ } finally {
1095
+ if (thrown) {
1096
+ try { await reader.cancel(thrown); } catch { /* already cancelled */ }
1097
+ try { reader.releaseLock(); } catch { /* already released */ }
1098
+ throw thrown;
1099
+ }
1100
+ try { reader.releaseLock(); } catch { /* already released */ }
1101
+ }
1102
+ }
1103
+
1104
+ // ─── deliver() helpers ──────────────────────────────────────────
1105
+
1106
+ /**
1107
+ * Wraps the caller's observed Promise so it only settles on a valid
1108
+ * `ObservedDeliveryReceipt` (per RFC: at least one of messageId /
1109
+ * sessionId must be a non-empty string). Invalid receipts and
1110
+ * rejections leave the returned Promise pending — the race's timeout
1111
+ * or abort branches take over.
1112
+ *
1113
+ * @private
1114
+ * @param {Promise<ObservedDeliveryReceipt>} source
1115
+ * @returns {Promise<ObservedDeliveryReceipt>}
1116
+ */
1117
+ _waitForValidReceipt(source) {
1118
+ return new Promise((resolve) => {
1119
+ Promise.resolve(source).then(
1120
+ (receipt) => {
1121
+ if (this._validateReceipt(receipt)) {
1122
+ resolve(receipt);
1123
+ }
1124
+ // invalid receipt: never resolve — treat as if observed never fired
1125
+ },
1126
+ () => {
1127
+ // observed rejected: never resolve — race's timeout/abort take over
1128
+ }
1129
+ );
1130
+ });
1131
+ }
1132
+
1133
+ /**
1134
+ * Identity check: a receipt must be an object with at least one of
1135
+ * `messageId` or `sessionId` as a non-empty string. Caller-supplied
1136
+ * observation channels can produce arbitrary shapes; this gate
1137
+ * prevents an empty-resolve from being interpreted as a successful
1138
+ * delivery (a common shape of caller bug).
1139
+ *
1140
+ * @private
1141
+ * @param {unknown} receipt
1142
+ * @returns {boolean}
1143
+ */
1144
+ _validateReceipt(receipt) {
1145
+ if (!receipt || typeof receipt !== 'object') return false;
1146
+ const hasMsgId = typeof receipt.messageId === 'string' && receipt.messageId.length > 0;
1147
+ const hasSessionId = typeof receipt.sessionId === 'string' && receipt.sessionId.length > 0;
1148
+ return hasMsgId || hasSessionId;
1149
+ }
1150
+
1151
+ /**
1152
+ * Race a Promise against a timeout. Returns the resolved value if the
1153
+ * Promise wins, or `null` if the timeout fires first. Promise rejection
1154
+ * is treated as "did not arrive" (same as timeout, returns `null`).
1155
+ *
1156
+ * @private
1157
+ * @template T
1158
+ * @param {Promise<T>} promise
1159
+ * @param {number} ms
1160
+ * @returns {Promise<T | null>}
1161
+ */
1162
+ _raceObservedWithTimeout(promise, ms) {
1163
+ return new Promise((resolve) => {
1164
+ let settled = false;
1165
+ const timer = setTimeout(() => {
1166
+ if (settled) return;
1167
+ settled = true;
1168
+ resolve(null);
1169
+ }, Math.max(0, ms));
1170
+ Promise.resolve(promise).then(
1171
+ (value) => {
1172
+ if (settled) return;
1173
+ settled = true;
1174
+ clearTimeout(timer);
1175
+ resolve(value);
1176
+ },
1177
+ () => {
1178
+ if (settled) return;
1179
+ settled = true;
1180
+ clearTimeout(timer);
1181
+ resolve(null);
1182
+ }
1183
+ );
1184
+ });
1185
+ }
1186
+
1187
+ /**
1188
+ * Post-transport grace formula. Defaults to
1189
+ * `min(remainingBudget, max(5000ms, timeoutMs * 0.1))`. Caller override
1190
+ * is capped by remaining budget so it can never exceed the total timeout.
1191
+ *
1192
+ * @private
1193
+ * @param {number | undefined} override
1194
+ * @param {number} totalTimeoutMs
1195
+ * @param {number} remainingMs
1196
+ * @returns {number}
1197
+ */
1198
+ _computeGrace(override, totalTimeoutMs, remainingMs) {
1199
+ if (typeof override === 'number' && Number.isFinite(override) && override >= 0) {
1200
+ return Math.min(override, remainingMs);
1201
+ }
1202
+ const defaultGrace = Math.max(5000, Math.floor(totalTimeoutMs * 0.1));
1203
+ return Math.min(defaultGrace, remainingMs);
1204
+ }
1205
+
1206
+ /**
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.
1211
+ *
1212
+ * @private
1213
+ * @param {string} methodName
1214
+ * @param {{ expectsBackupPush?: boolean }} opts
1215
+ */
1216
+ _maybeWarnLowLevel(methodName, opts) {
1217
+ if (!opts || opts.expectsBackupPush !== true) return;
1218
+ if (this._lowLevelWarned.has(methodName)) return;
1219
+ this._lowLevelWarned.add(methodName);
1220
+ const verdict = methodName === 'sendInstant'
1221
+ ? 'HTTP 200 ≠ delivery confirmation'
1222
+ : 'rejection ≠ delivery failure';
1223
+ 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.`
1225
+ );
1226
+ }
1227
+
588
1228
  // ─── Crypto helpers (Web Crypto API) ────────────────────────────
589
1229
 
590
1230
  /**