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