@rei-standard/amsg-client 2.3.0-next.2 → 2.4.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 +40 -2
- package/dist/index.cjs +139 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.mjs +139 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。
|
|
4
4
|
|
|
5
|
+
## v2.4.0-next.0 — SSE consumer
|
|
6
|
+
|
|
7
|
+
新增 `consumeInstantStream(payload, endpointPath?, options)`:消费 amsg-instant 0.9.0+ 的 SSE 默认响应,按 frame 解析 `event: payload` / `event: done` / `event: error` 分发到 `options.onPayload`。前台场景下 push 不再绕 push service → SW → IDB → main thread 整条链路,延迟少一个数量级。详见下方 [SSE 流消费](#sse-流消费-consumeinstantstream240配合-amsg-instant-090)。`sendInstant()` 字节级不变;老调用方升级零成本。
|
|
8
|
+
|
|
5
9
|
## v2.3.0 — Shared push types
|
|
6
10
|
|
|
7
11
|
The client now re-exports `@rei-standard/amsg-shared` 的类型、运行时常量(`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`)、推送 builder(`buildContentPush` 等)和类型守卫(`isContentPush` 等)。调用方可以直接 `import { MessageKind, buildContentPush, isContentPush } from '@rei-standard/amsg-client'`,无需单独再装一个 `@rei-standard/amsg-shared` 依赖。client 本身在运行时不消费这些导出 —— 它们是给同时调 `ReiClient` 又在 Service Worker / 客户端处理推送的 app 用的便利出口。
|
|
@@ -180,7 +184,41 @@ await client.sendInstant({
|
|
|
180
184
|
- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
|
|
181
185
|
- 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
|
|
182
186
|
|
|
183
|
-
###
|
|
187
|
+
### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+)
|
|
188
|
+
|
|
189
|
+
`sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,省掉 push service → SW → IDB → window 的绕路,前台延迟从约 1–3s 降到次百毫秒。前台场景应该改用 `consumeInstantStream()`。
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
const abort = new AbortController();
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await client.consumeInstantStream(payload, '/instant', {
|
|
196
|
+
onPayload: async (push) => {
|
|
197
|
+
// 跟 SW 收到的 wire format 字节级一致:含 messageKind / sessionId / messageId
|
|
198
|
+
// 等。按 messageKind 分轨写 IDB / 渲染 / 更新 UI 状态机即可。
|
|
199
|
+
await routePushToIDB(push);
|
|
200
|
+
},
|
|
201
|
+
onError: (err) => log.warn('stream error', err), // 通知性,不抑制 throw
|
|
202
|
+
onDone: () => stopSpinner(),
|
|
203
|
+
signal: abort.signal,
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// 网络 / 协议 / abort / onPayload 抛错都会到这里
|
|
207
|
+
showError(err);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`:SSE 写失败或客户端断开时 amsg-instant 用它做 best-effort fallback push(同一 `messageId`,客户端按 ID 幂等去重即可)。
|
|
212
|
+
|
|
213
|
+
#### 错误语义
|
|
214
|
+
|
|
215
|
+
任何失败——`fetch` 网络异常、非 2xx 响应、非 `text/event-stream` `Content-Type`、SSE `event: error` 帧、`onPayload` 回调抛错、`signal` abort——都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不要把它当 try/catch 替代。
|
|
216
|
+
|
|
217
|
+
#### 端点 / transport 配置
|
|
218
|
+
|
|
219
|
+
`endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。
|
|
220
|
+
|
|
221
|
+
### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+)
|
|
184
222
|
|
|
185
223
|
`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护:
|
|
186
224
|
|
|
@@ -205,7 +243,7 @@ try {
|
|
|
205
243
|
}
|
|
206
244
|
```
|
|
207
245
|
|
|
208
|
-
服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0
|
|
246
|
+
服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0+)有同样的软清空二道防线,client 这一道主要省一次远端往返。
|
|
209
247
|
|
|
210
248
|
## 导出 API(Exports)
|
|
211
249
|
|
package/dist/index.cjs
CHANGED
|
@@ -202,6 +202,145 @@ var ReiClient = class {
|
|
|
202
202
|
});
|
|
203
203
|
return res.json();
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Consume an instant SSE stream.
|
|
207
|
+
*
|
|
208
|
+
* 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.
|
|
214
|
+
*
|
|
215
|
+
* @param {Object} payload - Instant message payload.
|
|
216
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
217
|
+
* @param {Object} options
|
|
218
|
+
* @param {Record<string, string>} [options.headers]
|
|
219
|
+
* @param {(payload: unknown) => Promise<void> | void} options.onPayload
|
|
220
|
+
* @param {(error: unknown) => void} [options.onError]
|
|
221
|
+
* @param {() => void} [options.onDone]
|
|
222
|
+
* @param {AbortSignal} [options.signal]
|
|
223
|
+
* @returns {Promise<void>}
|
|
224
|
+
*/
|
|
225
|
+
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}`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers,
|
|
247
|
+
body,
|
|
248
|
+
signal: options.signal
|
|
249
|
+
});
|
|
250
|
+
if (!res.ok) {
|
|
251
|
+
const text = await res.text().catch(() => "");
|
|
252
|
+
throw new Error(`Instant request failed: ${res.status} ${text}`);
|
|
253
|
+
}
|
|
254
|
+
const contentType = res.headers.get("content-type") || "";
|
|
255
|
+
if (!contentType.includes("text/event-stream")) {
|
|
256
|
+
const text = await res.text().catch(() => "");
|
|
257
|
+
throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
|
|
258
|
+
}
|
|
259
|
+
if (!res.body) {
|
|
260
|
+
throw new Error("Response body is null");
|
|
261
|
+
}
|
|
262
|
+
const reader = res.body.getReader();
|
|
263
|
+
const decoder = new TextDecoder();
|
|
264
|
+
let buffer = "";
|
|
265
|
+
let thrown;
|
|
266
|
+
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
|
+
}
|
|
317
|
+
if (options.onDone) options.onDone();
|
|
318
|
+
} 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
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
reader.releaseLock();
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
throw thrown;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
reader.releaseLock();
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
205
344
|
/**
|
|
206
345
|
* Update an existing scheduled message.
|
|
207
346
|
*
|
package/dist/index.d.cts
CHANGED
|
@@ -275,6 +275,155 @@ class ReiClient {
|
|
|
275
275
|
return res.json();
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Consume an instant SSE stream.
|
|
280
|
+
*
|
|
281
|
+
* Error semantics: any failure (network, protocol, abort, `onPayload`
|
|
282
|
+
* callback throwing) rejects the returned Promise. `options.onError`,
|
|
283
|
+
* when provided, is a side-channel notification (e.g. for logging or
|
|
284
|
+
* UI flashes) and fires before the rejection — it does not suppress
|
|
285
|
+
* it. Always wrap calls in `try / await` and treat the rejection as
|
|
286
|
+
* the canonical error path.
|
|
287
|
+
*
|
|
288
|
+
* @param {Object} payload - Instant message payload.
|
|
289
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
290
|
+
* @param {Object} options
|
|
291
|
+
* @param {Record<string, string>} [options.headers]
|
|
292
|
+
* @param {(payload: unknown) => Promise<void> | void} options.onPayload
|
|
293
|
+
* @param {(error: unknown) => void} [options.onError]
|
|
294
|
+
* @param {() => void} [options.onDone]
|
|
295
|
+
* @param {AbortSignal} [options.signal]
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
|
|
299
|
+
this._sanitizeAvatarUrl(payload);
|
|
300
|
+
const json = JSON.stringify(payload);
|
|
301
|
+
this._assertPayloadSize(json, 'consumeInstantStream');
|
|
302
|
+
|
|
303
|
+
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
|
304
|
+
let body;
|
|
305
|
+
|
|
306
|
+
if (this._instantEncryption === false) {
|
|
307
|
+
body = json;
|
|
308
|
+
if (this._instantClientToken) {
|
|
309
|
+
headers['X-Client-Token'] = this._instantClientToken;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
const encrypted = await this._encrypt(json);
|
|
313
|
+
headers['X-User-Id'] = this._userId;
|
|
314
|
+
headers['X-Payload-Encrypted'] = 'true';
|
|
315
|
+
headers['X-Encryption-Version'] = '1';
|
|
316
|
+
body = JSON.stringify(encrypted);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
|
320
|
+
const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers,
|
|
323
|
+
body,
|
|
324
|
+
signal: options.signal
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!res.ok) {
|
|
328
|
+
const text = await res.text().catch(() => '');
|
|
329
|
+
throw new Error(`Instant request failed: ${res.status} ${text}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const contentType = res.headers.get('content-type') || '';
|
|
333
|
+
if (!contentType.includes('text/event-stream')) {
|
|
334
|
+
const text = await res.text().catch(() => '');
|
|
335
|
+
throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!res.body) {
|
|
339
|
+
throw new Error('Response body is null');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const reader = res.body.getReader();
|
|
343
|
+
const decoder = new TextDecoder();
|
|
344
|
+
let buffer = '';
|
|
345
|
+
let thrown;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
while (true) {
|
|
349
|
+
const { done, value } = await reader.read();
|
|
350
|
+
if (done) break;
|
|
351
|
+
|
|
352
|
+
buffer += decoder.decode(value, { stream: true });
|
|
353
|
+
const parts = buffer.split('\n\n');
|
|
354
|
+
buffer = parts.pop() || ''; // last part may be incomplete
|
|
355
|
+
|
|
356
|
+
for (const part of parts) {
|
|
357
|
+
if (!part.trim()) continue;
|
|
358
|
+
|
|
359
|
+
let eventName = 'message';
|
|
360
|
+
// Per SSE spec multiple `data:` lines in one event concatenate
|
|
361
|
+
// with `\n`. Our own server always emits a single data line,
|
|
362
|
+
// but `consumeInstantStream` is a general-purpose consumer.
|
|
363
|
+
let data = '';
|
|
364
|
+
|
|
365
|
+
const lines = part.split('\n');
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
if (line.startsWith(':')) continue; // keepalive comment
|
|
368
|
+
if (line.startsWith('event:')) {
|
|
369
|
+
eventName = line.slice(6).trim();
|
|
370
|
+
} else if (line.startsWith('data:')) {
|
|
371
|
+
const piece = line.slice(5).trim();
|
|
372
|
+
data = data ? `${data}\n${piece}` : piece;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (eventName === 'done') {
|
|
377
|
+
if (options.onDone) options.onDone();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (eventName === 'error') {
|
|
382
|
+
let parsedErr;
|
|
383
|
+
try {
|
|
384
|
+
parsedErr = JSON.parse(data);
|
|
385
|
+
} catch {
|
|
386
|
+
parsedErr = { code: 'PARSE_ERROR', message: data };
|
|
387
|
+
}
|
|
388
|
+
const err = new Error(parsedErr.message || 'Stream error');
|
|
389
|
+
err.code = parsedErr.code;
|
|
390
|
+
thrown = err;
|
|
391
|
+
return; // exit loop, finally re-throws
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (eventName === 'payload') {
|
|
395
|
+
let parsedPayload;
|
|
396
|
+
try {
|
|
397
|
+
parsedPayload = JSON.parse(data);
|
|
398
|
+
} catch {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (options.onPayload) {
|
|
402
|
+
await options.onPayload(parsedPayload);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Stream ended without `event: done` — treat EOF as done.
|
|
409
|
+
if (options.onDone) options.onDone();
|
|
410
|
+
} catch (err) {
|
|
411
|
+
thrown = err;
|
|
412
|
+
} finally {
|
|
413
|
+
// Always notify onError (side-channel) and always throw — callers
|
|
414
|
+
// rely on Promise rejection as the canonical failure signal.
|
|
415
|
+
if (thrown) {
|
|
416
|
+
try { await reader.cancel(thrown); } catch { /* already cancelled */ }
|
|
417
|
+
if (options.onError) {
|
|
418
|
+
try { options.onError(thrown); } catch { /* observer can't break the throw */ }
|
|
419
|
+
}
|
|
420
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
421
|
+
throw thrown;
|
|
422
|
+
}
|
|
423
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
278
427
|
/**
|
|
279
428
|
* Update an existing scheduled message.
|
|
280
429
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -275,6 +275,155 @@ class ReiClient {
|
|
|
275
275
|
return res.json();
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Consume an instant SSE stream.
|
|
280
|
+
*
|
|
281
|
+
* Error semantics: any failure (network, protocol, abort, `onPayload`
|
|
282
|
+
* callback throwing) rejects the returned Promise. `options.onError`,
|
|
283
|
+
* when provided, is a side-channel notification (e.g. for logging or
|
|
284
|
+
* UI flashes) and fires before the rejection — it does not suppress
|
|
285
|
+
* it. Always wrap calls in `try / await` and treat the rejection as
|
|
286
|
+
* the canonical error path.
|
|
287
|
+
*
|
|
288
|
+
* @param {Object} payload - Instant message payload.
|
|
289
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
290
|
+
* @param {Object} options
|
|
291
|
+
* @param {Record<string, string>} [options.headers]
|
|
292
|
+
* @param {(payload: unknown) => Promise<void> | void} options.onPayload
|
|
293
|
+
* @param {(error: unknown) => void} [options.onError]
|
|
294
|
+
* @param {() => void} [options.onDone]
|
|
295
|
+
* @param {AbortSignal} [options.signal]
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
async consumeInstantStream(payload, endpointPath = '/instant', options = {}) {
|
|
299
|
+
this._sanitizeAvatarUrl(payload);
|
|
300
|
+
const json = JSON.stringify(payload);
|
|
301
|
+
this._assertPayloadSize(json, 'consumeInstantStream');
|
|
302
|
+
|
|
303
|
+
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
|
304
|
+
let body;
|
|
305
|
+
|
|
306
|
+
if (this._instantEncryption === false) {
|
|
307
|
+
body = json;
|
|
308
|
+
if (this._instantClientToken) {
|
|
309
|
+
headers['X-Client-Token'] = this._instantClientToken;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
const encrypted = await this._encrypt(json);
|
|
313
|
+
headers['X-User-Id'] = this._userId;
|
|
314
|
+
headers['X-Payload-Encrypted'] = 'true';
|
|
315
|
+
headers['X-Encryption-Version'] = '1';
|
|
316
|
+
body = JSON.stringify(encrypted);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
|
320
|
+
const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers,
|
|
323
|
+
body,
|
|
324
|
+
signal: options.signal
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!res.ok) {
|
|
328
|
+
const text = await res.text().catch(() => '');
|
|
329
|
+
throw new Error(`Instant request failed: ${res.status} ${text}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const contentType = res.headers.get('content-type') || '';
|
|
333
|
+
if (!contentType.includes('text/event-stream')) {
|
|
334
|
+
const text = await res.text().catch(() => '');
|
|
335
|
+
throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!res.body) {
|
|
339
|
+
throw new Error('Response body is null');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const reader = res.body.getReader();
|
|
343
|
+
const decoder = new TextDecoder();
|
|
344
|
+
let buffer = '';
|
|
345
|
+
let thrown;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
while (true) {
|
|
349
|
+
const { done, value } = await reader.read();
|
|
350
|
+
if (done) break;
|
|
351
|
+
|
|
352
|
+
buffer += decoder.decode(value, { stream: true });
|
|
353
|
+
const parts = buffer.split('\n\n');
|
|
354
|
+
buffer = parts.pop() || ''; // last part may be incomplete
|
|
355
|
+
|
|
356
|
+
for (const part of parts) {
|
|
357
|
+
if (!part.trim()) continue;
|
|
358
|
+
|
|
359
|
+
let eventName = 'message';
|
|
360
|
+
// Per SSE spec multiple `data:` lines in one event concatenate
|
|
361
|
+
// with `\n`. Our own server always emits a single data line,
|
|
362
|
+
// but `consumeInstantStream` is a general-purpose consumer.
|
|
363
|
+
let data = '';
|
|
364
|
+
|
|
365
|
+
const lines = part.split('\n');
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
if (line.startsWith(':')) continue; // keepalive comment
|
|
368
|
+
if (line.startsWith('event:')) {
|
|
369
|
+
eventName = line.slice(6).trim();
|
|
370
|
+
} else if (line.startsWith('data:')) {
|
|
371
|
+
const piece = line.slice(5).trim();
|
|
372
|
+
data = data ? `${data}\n${piece}` : piece;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (eventName === 'done') {
|
|
377
|
+
if (options.onDone) options.onDone();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (eventName === 'error') {
|
|
382
|
+
let parsedErr;
|
|
383
|
+
try {
|
|
384
|
+
parsedErr = JSON.parse(data);
|
|
385
|
+
} catch {
|
|
386
|
+
parsedErr = { code: 'PARSE_ERROR', message: data };
|
|
387
|
+
}
|
|
388
|
+
const err = new Error(parsedErr.message || 'Stream error');
|
|
389
|
+
err.code = parsedErr.code;
|
|
390
|
+
thrown = err;
|
|
391
|
+
return; // exit loop, finally re-throws
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (eventName === 'payload') {
|
|
395
|
+
let parsedPayload;
|
|
396
|
+
try {
|
|
397
|
+
parsedPayload = JSON.parse(data);
|
|
398
|
+
} catch {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (options.onPayload) {
|
|
402
|
+
await options.onPayload(parsedPayload);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Stream ended without `event: done` — treat EOF as done.
|
|
409
|
+
if (options.onDone) options.onDone();
|
|
410
|
+
} catch (err) {
|
|
411
|
+
thrown = err;
|
|
412
|
+
} finally {
|
|
413
|
+
// Always notify onError (side-channel) and always throw — callers
|
|
414
|
+
// rely on Promise rejection as the canonical failure signal.
|
|
415
|
+
if (thrown) {
|
|
416
|
+
try { await reader.cancel(thrown); } catch { /* already cancelled */ }
|
|
417
|
+
if (options.onError) {
|
|
418
|
+
try { options.onError(thrown); } catch { /* observer can't break the throw */ }
|
|
419
|
+
}
|
|
420
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
421
|
+
throw thrown;
|
|
422
|
+
}
|
|
423
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
278
427
|
/**
|
|
279
428
|
* Update an existing scheduled message.
|
|
280
429
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -180,6 +180,145 @@ var ReiClient = class {
|
|
|
180
180
|
});
|
|
181
181
|
return res.json();
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Consume an instant SSE stream.
|
|
185
|
+
*
|
|
186
|
+
* Error semantics: any failure (network, protocol, abort, `onPayload`
|
|
187
|
+
* callback throwing) rejects the returned Promise. `options.onError`,
|
|
188
|
+
* when provided, is a side-channel notification (e.g. for logging or
|
|
189
|
+
* UI flashes) and fires before the rejection — it does not suppress
|
|
190
|
+
* it. Always wrap calls in `try / await` and treat the rejection as
|
|
191
|
+
* the canonical error path.
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} payload - Instant message payload.
|
|
194
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
195
|
+
* @param {Object} options
|
|
196
|
+
* @param {Record<string, string>} [options.headers]
|
|
197
|
+
* @param {(payload: unknown) => Promise<void> | void} options.onPayload
|
|
198
|
+
* @param {(error: unknown) => void} [options.onError]
|
|
199
|
+
* @param {() => void} [options.onDone]
|
|
200
|
+
* @param {AbortSignal} [options.signal]
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
*/
|
|
203
|
+
async consumeInstantStream(payload, endpointPath = "/instant", options = {}) {
|
|
204
|
+
this._sanitizeAvatarUrl(payload);
|
|
205
|
+
const json = JSON.stringify(payload);
|
|
206
|
+
this._assertPayloadSize(json, "consumeInstantStream");
|
|
207
|
+
const headers = { "Content-Type": "application/json", ...options.headers || {} };
|
|
208
|
+
let body;
|
|
209
|
+
if (this._instantEncryption === false) {
|
|
210
|
+
body = json;
|
|
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}`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers,
|
|
225
|
+
body,
|
|
226
|
+
signal: options.signal
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
const text = await res.text().catch(() => "");
|
|
230
|
+
throw new Error(`Instant request failed: ${res.status} ${text}`);
|
|
231
|
+
}
|
|
232
|
+
const contentType = res.headers.get("content-type") || "";
|
|
233
|
+
if (!contentType.includes("text/event-stream")) {
|
|
234
|
+
const text = await res.text().catch(() => "");
|
|
235
|
+
throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`);
|
|
236
|
+
}
|
|
237
|
+
if (!res.body) {
|
|
238
|
+
throw new Error("Response body is null");
|
|
239
|
+
}
|
|
240
|
+
const reader = res.body.getReader();
|
|
241
|
+
const decoder = new TextDecoder();
|
|
242
|
+
let buffer = "";
|
|
243
|
+
let thrown;
|
|
244
|
+
try {
|
|
245
|
+
while (true) {
|
|
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
|
+
}
|
|
295
|
+
if (options.onDone) options.onDone();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
thrown = err;
|
|
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
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
reader.releaseLock();
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
throw thrown;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
reader.releaseLock();
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
183
322
|
/**
|
|
184
323
|
* Update an existing scheduled message.
|
|
185
324
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rei-standard/amsg-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0-next.0",
|
|
4
4
|
"description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=20"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@rei-standard/amsg-shared": "0.1.0
|
|
36
|
+
"@rei-standard/amsg-shared": "0.1.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"tsup": "^8.0.0",
|