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