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