From 3d5da25cd85071bf58f20c7687435425a0c61bd9 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Sat, 30 May 2026 18:51:23 +0800 Subject: [PATCH 1/9] feat(amsg): SSE default transport + lifecycle hooks + SSE consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amsg-instant 0.9.0-next.0: - Default transport switched to SSE (Server-Sent Events). Requests without `Accept: application/json` now receive `text/event-stream` with each push framed as `event: payload`, terminated by `event: done`, and `event: error` for in-stream business failures. HTTP 200 throughout; status codes no longer carry error semantics. - `Accept: application/json` opts back into the 0.8.x pure Web Push path, byte-for-byte preserved (`{success, data}` body, 1500ms pacing, HTTP status mapping). - New `onBeforeLoop` / `onAfterLoop` lifecycle hooks for kicking off side tasks in parallel with the main LLM loop and flushing extra payloads before stream close. Active in both transport modes. - Best-effort fallback: SSE write failure or client disconnect routes subsequent payloads to Web Push via `pushSubscription` (still required in the SSE body). Same `messageId` across both transports; client dedupes by id. - HookError no longer echoed as `event: error` after its in-loop diagnostic already shipped as `event: payload` (dedup decision — see CHANGELOG). - Internal transport abstraction: all push paths funnel through `deliverPush()` + `ensureStableMessageId()`. Auto-generated messageId format changed from `msg__chunk_` to `msg_`. - SSE mode drops the 1500ms inter-push spacing (push gateway concern, not stream concern); pure-push keeps it. - Module-scoped TextEncoder + pre-encoded keepalive/done bytes; constants (`MESSAGE_TYPE.INSTANT` / `PUSH_SOURCE.INSTANT`) instead of literals through the processor; safer controller.close() / enqueue paths. amsg-client 2.4.0-next.0: - New `consumeInstantStream(payload, endpointPath?, options)` for consuming amsg-instant 0.9.0+ SSE responses. Calls `options.onPayload` per frame, optional `onError` / `onDone` / `signal`. - Always-rejects-on-failure semantics. `onError` is a notification side-channel that fires before the rejection, not a try/catch replacement. - SSE spec compliance: multi-line `data:` concatenated with `\n`; non-2xx and non-`text/event-stream` responses surface as immediate errors. - `reader.cancel(err)` on failure to close the underlying connection. - `sendInstant()` byte-for-byte unchanged. Pre-release pinned to `next` dist-tag — do NOT promote amsg-instant@0.9.0 to latest before the downstream SSE consumer (amsg-client 2.4.0-next.0+ `consumeInstantStream`) is wired up in the app. Tests: 177/177 pass (171 instant + 6 client). New parallel SSE-mode coverage added for each existing pure-push test scenario. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/client/CHANGELOG.md | 28 +++ packages/rei-standard-amsg/client/README.md | 38 +++ .../rei-standard-amsg/client/package.json | 2 +- .../rei-standard-amsg/client/src/index.js | 149 +++++++++++ .../rei-standard-amsg/instant/CHANGELOG.md | 58 +++++ packages/rei-standard-amsg/instant/README.md | 106 +++++++- .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/instant/src/index.js | 189 +++++++++++++- .../instant/src/message-processor.js | 103 +++++--- .../instant/test/agentic-loop.test.mjs | 234 +++++++++++++++++- .../instant/test/cloudflare-adapter.test.mjs | 6 +- .../instant/test/e2e.test.mjs | 49 +++- .../instant/test/handler.test.mjs | 114 ++++++++- .../instant/test/helpers.mjs | 98 ++++++++ .../instant/test/pushpayloads-array.test.mjs | 5 +- .../instant/test/reasoning-push.test.mjs | 6 +- 16 files changed, 1113 insertions(+), 74 deletions(-) diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index aad3feb..ae9de29 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog — @rei-standard/amsg-client +## 2.4.0-next.0 — `consumeInstantStream()` SSE consumer (pre-release) + +发布在 `next` dist-tag。配套 `@rei-standard/amsg-instant@0.9.0-next.0+` 的 SSE 默认模式;老的 `sendInstant()` 字节级不变。 + +### 新增 `consumeInstantStream(payload, endpointPath?, options)` + +POST 到 amsg-instant 的 `/instant` 或 `/continue` 端点,按 SSE frame 解析 `event: payload` / `event: error` / `event: done`,分发到 `options.onPayload` 回调;可被 `options.signal` 中止。 + +```js +await client.consumeInstantStream(payload, '/instant', { + onPayload: async (p) => routeToIDB(p), // 必填 + onError: (err) => log(err), // 可选;通知用,不抑制 throw + onDone: () => stopSpinner(), // 可选 + signal: abortController.signal, // 可选 +}); +``` + +错误语义:网络 / 协议 / abort / `onPayload` 抛错都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不是 try/catch 替代——总是 `await` + 外层 `try/catch` 处理。 + +加密 / 明文两种 transport 共享构造器配置(`instantEncryption` / `instantClientToken`),用法和 `sendInstant` 一致。请求体跟 `sendInstant` 完全一样——包括必须的 `pushSubscription`(SSE 写失败时框架会用它做 fallback push)。 + +### Spec 细节 + +- 多行 `data:` 按 SSE 规范用 `\n` 拼接(不是后写覆盖) +- 非 2xx / 非 `text/event-stream` 响应立即 throw,不进 parser +- 出错时 `reader.cancel(err)` 关闭底层连接,避免 fetch stream 残留至 GC +- AbortError 与其他错误一视同仁走 reject——caller 用 `signal` 主动取消时也能拿到 rejection + ## 2.3.0 — Dependency bump - 依赖更新:同步升级 `@rei-standard/amsg-shared` 至稳定版 `0.1.0`。 diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index 0ac83b3..979186d 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -2,6 +2,10 @@ `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。 +## v2.4.0-next.0 — SSE consumer + +新增 `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()` 字节级不变;老调用方升级零成本。 + ## v2.3.0 — Shared push types 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,6 +184,40 @@ await client.sendInstant({ - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。 - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 +### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+) + +`sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,省掉 push service → SW → IDB → window 的绕路,前台延迟从约 1–3s 降到次百毫秒。前台场景应该改用 `consumeInstantStream()`。 + +```js +const abort = new AbortController(); + +try { + await client.consumeInstantStream(payload, '/instant', { + onPayload: async (push) => { + // 跟 SW 收到的 wire format 字节级一致:含 messageKind / sessionId / messageId + // 等。按 messageKind 分轨写 IDB / 渲染 / 更新 UI 状态机即可。 + await routePushToIDB(push); + }, + onError: (err) => log.warn('stream error', err), // 通知性,不抑制 throw + onDone: () => stopSpinner(), + signal: abort.signal, + }); +} catch (err) { + // 网络 / 协议 / abort / onPayload 抛错都会到这里 + showError(err); +} +``` + +请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`:SSE 写失败或客户端断开时 amsg-instant 用它做 best-effort fallback push(同一 `messageId`,客户端按 ID 幂等去重即可)。 + +#### 错误语义 + +任何失败——`fetch` 网络异常、非 2xx 响应、非 `text/event-stream` `Content-Type`、SSE `event: error` 帧、`onPayload` 回调抛错、`signal` abort——都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不要把它当 try/catch 替代。 + +#### 端点 / transport 配置 + +`endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。 + ### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+) `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护: diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index a5a272f..492b003 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.3.0", + "version": "2.4.0-next.0", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 2c8ac8d..9e37e75 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -273,6 +273,155 @@ export class ReiClient { return res.json(); } + /** + * Consume an instant SSE stream. + * + * Error semantics: any failure (network, protocol, abort, `onPayload` + * callback throwing) rejects the returned Promise. `options.onError`, + * when provided, is a side-channel notification (e.g. for logging or + * UI flashes) and fires before the rejection — it does not suppress + * it. Always wrap calls in `try / await` and treat the rejection as + * the canonical error path. + * + * @param {Object} payload - Instant message payload. + * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'. + * @param {Object} options + * @param {Record} [options.headers] + * @param {(payload: unknown) => Promise | void} options.onPayload + * @param {(error: unknown) => void} [options.onError] + * @param {() => void} [options.onDone] + * @param {AbortSignal} [options.signal] + * @returns {Promise} + */ + async consumeInstantStream(payload, endpointPath = '/instant', options = {}) { + this._sanitizeAvatarUrl(payload); + const json = JSON.stringify(payload); + this._assertPayloadSize(json, 'consumeInstantStream'); + + const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; + let body; + + if (this._instantEncryption === false) { + body = json; + if (this._instantClientToken) { + headers['X-Client-Token'] = this._instantClientToken; + } + } else { + const encrypted = await this._encrypt(json); + headers['X-User-Id'] = this._userId; + headers['X-Payload-Encrypted'] = 'true'; + headers['X-Encryption-Version'] = '1'; + body = JSON.stringify(encrypted); + } + + const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`; + const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, { + method: 'POST', + headers, + body, + signal: options.signal + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Instant request failed: ${res.status} ${text}`); + } + + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('text/event-stream')) { + const text = await res.text().catch(() => ''); + throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`); + } + + if (!res.body) { + throw new Error('Response body is null'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let thrown; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; // last part may be incomplete + + for (const part of parts) { + if (!part.trim()) continue; + + let eventName = 'message'; + // Per SSE spec multiple `data:` lines in one event concatenate + // with `\n`. Our own server always emits a single data line, + // but `consumeInstantStream` is a general-purpose consumer. + let data = ''; + + const lines = part.split('\n'); + for (const line of lines) { + if (line.startsWith(':')) continue; // keepalive comment + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const piece = line.slice(5).trim(); + data = data ? `${data}\n${piece}` : piece; + } + } + + if (eventName === 'done') { + if (options.onDone) options.onDone(); + return; + } + + if (eventName === 'error') { + let parsedErr; + try { + parsedErr = JSON.parse(data); + } catch { + parsedErr = { code: 'PARSE_ERROR', message: data }; + } + const err = new Error(parsedErr.message || 'Stream error'); + err.code = parsedErr.code; + thrown = err; + return; // exit loop, finally re-throws + } + + if (eventName === 'payload') { + let parsedPayload; + try { + parsedPayload = JSON.parse(data); + } catch { + continue; + } + if (options.onPayload) { + await options.onPayload(parsedPayload); + } + } + } + } + + // Stream ended without `event: done` — treat EOF as done. + if (options.onDone) options.onDone(); + } catch (err) { + thrown = err; + } finally { + // Always notify onError (side-channel) and always throw — callers + // rely on Promise rejection as the canonical failure signal. + if (thrown) { + try { await reader.cancel(thrown); } catch { /* already cancelled */ } + if (options.onError) { + try { options.onError(thrown); } catch { /* observer can't break the throw */ } + } + try { reader.releaseLock(); } catch { /* already released */ } + throw thrown; + } + try { reader.releaseLock(); } catch { /* already released */ } + } + } + /** * Update an existing scheduled message. * diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 6284340..449beb7 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog — @rei-standard/amsg-instant +## 0.9.0-next.0 — SSE 流式传输 + 生命周期 hooks (pre-release) + +发布在 `next` dist-tag。**不要在下游 SSE consumer(`@rei-standard/amsg-client@2.4.0-next.0+` 的 `consumeInstantStream`)接入完成前升级到 latest。** + +### 默认传输模式切换为 SSE + +不带 `Accept: application/json` 的请求现在返回 `text/event-stream`:每条 push 通过 `event: payload\ndata: \n\n` 流式投递,流末尾发 `event: done\ndata: {}\n\n`。结果直达主线程(不绕 push service → SW → IDB → window 这条链路),延迟从约 1–3s 降到次百毫秒;iOS WebKit 也不再为每条 payload 触发系统通知。 + +显式带 `Accept: application/json` 走 0.8.x 既有的纯 Web Push 路径,行为字节级不变——`{"success": true, "data": {...}}` JSON 响应、错误码与状态码映射、1500ms 间隔节奏都保留。 + +> 请求 body 仍**必须**带 `pushSubscription`:SSE 写失败或客户端断开时框架用它做 best-effort fallback push。 + +### Best-effort SSE → Web Push fallback + +SSE 写入抛错或 `request.signal.aborted` 触发 → 当前及后续 payload 自动走 `sendPushWithMaybeBlob` 转发到 Web Push 通道。同一 payload 在 SSE 和 fallback Push 上共用同一 `messageId`,客户端按 ID 幂等去重即可。 + +第一版**不做**严格 at-least-once / exactly-once: + +- SSE `controller.enqueue()` 只意味着字节进了 Worker 出口队列,不代表客户端已读完并入库 +- 可能重复(SSE 发了但客户端没处理完就断了,Push 又补发同一条) +- 也可能少量丢失 in-flight payload +- 严格交付保证需要 ACK + replay buffer,这版不引入 + +### 新增 `onBeforeLoop` / `onAfterLoop` 生命周期 hook + +```ts +onBeforeLoop?: (ctx: { requestBody, sessionId, metadata }) => unknown | Promise; +onAfterLoop?: (ctx: { deliver, sessionId, metadata, requestBody, pending }) => Promise; +``` + +`onBeforeLoop` 在主 LLM loop 启动**前**调用,约定 hook 同步启动副任务并返回 handle 对象(例如 `{ emotionEval: runEmotionEval(...) }`,里面的 promise 已经在跑)。框架只 await 函数返回——**不**会替你 await 副任务本身。返回值作为 `pending` 透传给 `onAfterLoop`,由后者按自己的结构 `await` 并通过 `deliver(payload)` 追加 push。 + +两个 hook 在 SSE 与纯 Push 两种传输模式下都生效,`deliver` 抹平差异——hook 作者不用关心当前哪种传输。`requestBody` 透传给 hook(框架不解析调用方的自定义字段)。 + +### 流内业务错误 + +SSE 流已开后,业务错误(`LlmCallError`、未知异常)通过 `event: error\ndata: <完整 ErrorPush>\n\n` 投递,HTTP 状态始终是 200——SSE 模式不能再靠 HTTP 状态码表达错误。客户端按 `messageKind === 'error'` 分轨即可,与 push 通道的 ErrorPush 是同一形状。 + +**`HookError` 例外**:hook throw 时框架已经在 loop 内通过 `deliver` 把诊断 ErrorPush 作为 `event: payload` 送出,外层 catch 不再重复发 `event: error`——同一个逻辑错误送两次会让下游错误处理器双触发。这是这版有意的取舍;未来加新的"开 loop 后才抛"的错误类型时,按"诊断是否已通过 deliver 出去"判断是否要 emit 外层 `event: error`。 + +### Keepalive + +SSE 空闲时每 15 秒发一行 `: keepalive\n\n` 注释,防 CDN / 反向代理的空连接超时断连。 + +### 内部 transport abstraction + +所有 push 透传统一走 `deliverPush()` 入口,`ensureStableMessageId()` 在边界一次性兜底 `messageId`——之前散落在 `sendPushesSequentially` 的 fallback id 生成逻辑收敛到这里。`createInstantHandler` 把 `ctx.deliver` 设成 SSE enqueue 或 `sendPushWithMaybeBlob`,下游 push builder(reasoning / content / tool_request / error)对传输无感。 + +副作用:caller 没显式 set `messageId` 时,框架自动生成的 ID 格式从 `msg__chunk_` 改为 `msg_`——chunk 位置信息一直在 `messageIndex` / `totalMessages` 字段里,重复编码到 ID 反而误导客户端 dedup 实现。hook 自己 set 的 `messageId` 不受影响。 + +### 其他 + +- SSE 模式下 `sendPushesSequentially` 跳过 1500ms 间隔(push gateway 节流不适用于流式直发),纯 Push 模式保留原节奏。多 push 的 SSE 响应感知延迟少 N × 1.5s +- 内部全面切到 `MESSAGE_TYPE.INSTANT` / `PUSH_SOURCE.INSTANT` 常量 +- SSE 热路径:`TextEncoder` 与 keepalive / done 字节预编码到 module 顶层(不再每请求 `new TextEncoder()` / 每 15s 重新 encode) +- `controller.close()` / `controller.enqueue()` 全部包在 try/swallow,避免 errored stream 上再次操作炸出 `TypeError` +- abort listener 命名 + finally 清理,不留 dangling 引用 + ## 0.8.2 — readReasoningContent fallback - **Enhancement**: `readReasoningContent` 添加 fallback 支持。当原生 `reasoning_content` 字段缺失时,会 fallback 检查 `message.content` 是否包含 `...`、`...` 或 `...` 并提取,提供对更多模型(例如 DeepSeek-R1-Distill)的原生兼容。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 76a5f69..45581d1 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -37,6 +37,8 @@ npm install @rei-standard/amsg-instant | `fetch` | function | ❌ | 自定义 fetch(测试 / 自建代理用)。同时用于 **LLM 调用** 和 **Web Push 推送**两个出口。 | | `onEvent` | function | ❌ | 事件钩子:`request` / `llm_done` / `push_sent` / `error`(明文模式下 `request` 事件不再带 userId —— 如果需要按用户分流日志,从 `payload.contactName` 或 `payload.metadata` 自取) | | `onLLMOutput` | function | ❌ | **0.7.0+**:每轮 LLM 输出后的决策钩子。配了它就进 agentic loop 模式;不配则走 v0.6 老路径(字节级兼容)。见 [Agentic Loop](#agentic-loop070) | +| `onBeforeLoop` | function | ❌ | **0.9.0+**:主 LLM loop 启动前调用,约定同步启动副任务并返回 handle 对象。返回值透传给 `onAfterLoop` 的 `pending`。SSE / 纯 Push 两种传输模式都生效。见 [生命周期 hooks](#生命周期-hooks-onbeforeloop--onafterloop090) | +| `onAfterLoop` | function | ❌ | **0.9.0+**:主 loop 结束后、流关闭前调用,从 `pending` 拿到 `onBeforeLoop` 返回的副任务 handle,await 完用 `deliver(payload)` 追加 push | | `blobStore` | object | ❌ | **0.7.0+**:可选 blob 后端。push payload UTF-8 字节超过 `maxInlineBytes`(默认 2600)时自动把 body 写进 store、改推 200 B envelope。见 [BlobStore](#blobstore070) | | `multipart` | object | ❌ | **0.8.0+**:通用 multipart transport。超出 inline、且没配 BlobStore 时,任意 JSON-safe payload 都可拆成 `_multipart` 分片。默认 `enabled:true`、`maxChunkBytes:1800`、`ttlMs:60000`、`maxChunks:128`、`maxTotalBytes:256000`。见 [Generic multipart transport](#generic-multipart-transport080)。 | | `maxLoopIterations` | number | ❌ | **0.7.0+**:单次 worker 调用内 `decision:'continue'` 的硬上限,默认 10。仅防本进程内 hook 反复 continue 失控;跨请求的 `/continue` 洪水攻击由上游 auth/rate-limit 处理 | @@ -98,12 +100,44 @@ const handler = createInstantHandler({ ### HTTP 协议 -**请求**: +#### 传输模式协商(0.9.0+) + +| 请求头 | 响应 | 适用场景 | +|---------------------------------|-----------------------------------|-----------------------------------------------------| +| 缺省 / 任意其他 Accept | `Content-Type: text/event-stream` | **默认**。每条 push 走 SSE 流式直推,前台主线程直接消化,iOS 不爆通知,断流时自动 fallback Web Push | +| `Accept: application/json` | `Content-Type: application/json` | 显式 opt-out 回到 0.8.x 纯 Web Push 行为;HTTP 状态码 + JSON body 错误语义都保留 | + +`pushSubscription` 在两种模式下都**必填**——SSE 模式下用作断流 / 写入失败时的 fallback 通道。 + +SSE wire format: + +``` +: keepalive ← 每 15s 一行,防 CDN/proxy 超时 + +event: payload ← 每条 push,data 是与 Web Push 通道一字节相同的 JSON +data: {"messageKind":"reasoning","sessionId":"sess_...",...} + +event: payload +data: {"messageKind":"content","messageId":"msg_...","message":"hello","messageIndex":1,"totalMessages":2,...} + +event: payload +data: {"messageKind":"content","messageId":"msg_...","message":"second","messageIndex":2,"totalMessages":2,...} + +event: done ← 流正常结束的最终信号;客户端可用 stream EOF 兜底 +data: {} +``` + +业务错误(LLM 调用失败、未知异常等)在流已开后通过 `event: error\ndata: <完整 ErrorPush>` 投递,HTTP 状态始终是 200——SSE 模式下不能再靠 HTTP 状态码表达错误。`HookError` 例外:诊断 ErrorPush 已经作为 `event: payload` 送出,不重复发 `event: error`。 + +> 客户端实现见 [`@rei-standard/amsg-client`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/client/README.md) 的 `consumeInstantStream()`。验证非 SSE 响应(含错误页 / 非 2xx)必须先看 `Content-Type` + status,再进 stream parser。 + +#### 请求 ```http POST /instant Authorization: Bearer ← 仅当 tokenSigningKey 配置时检查 X-Client-Token: ← 仅当 clientToken 配置时检查 +Accept: application/json ← 可选;显式走纯 Web Push 路径(0.9.0+) Content-Type: application/json { @@ -198,7 +232,9 @@ curl -X POST https://instant.example.com/instant \ 如果想绕开这个规则(比如代理路径很奇怪),传完整 `…/chat/completions` 即可。 -**响应**(成功): +**响应**(SSE 默认模式,0.9.0+):见上面 [传输模式协商](#传输模式协商090) 的 wire format。 + +**响应**(`Accept: application/json` opt-out 模式 / 0.8.x 行为): ```json { @@ -210,8 +246,6 @@ curl -X POST https://instant.example.com/instant \ } ``` -**响应**(失败): - ```json { "success": false, "error": { "code": "LLM_CALL_FAILED", "message": "..." } } ``` @@ -305,11 +339,11 @@ type LLMOutputDecision = lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` 会被尊重,其余 2 个无论 hook 写什么都被覆盖): -| 字段 | 自动补充行为 | -|-----------------|-------------------------------------------------------------------| -| `messageId` | hook 未设 → lib 用 `msg__chunk_` 填上;hook 已设 → 保留 | -| `messageIndex` | 永远覆盖:1-based 数组下标(i + 1) | -| `totalMessages` | 永远覆盖:`pushPayloads.length` | +| 字段 | 自动补充行为 | +|-----------------|-----------------------------------------------------------------------------| +| `messageId` | hook 未设 → lib 用 `msg_` 填上(0.9.0 起;之前是 `msg__chunk_`,chunk 位置已在下两个字段,重复编码到 ID 反而误导);hook 已设 → 保留 | +| `messageIndex` | 永远覆盖:1-based 数组下标(i + 1) | +| `totalMessages` | 永远覆盖:`pushPayloads.length` | 剩下所有字段(`messageKind` / `notification` / `metadata` / kind 特定字段(e.g. tool_request 的 toolCalls / reasoning 的 reasoningContent / error 的 code) / 等)都是 per-push,caller 完全控制。每个 push 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 @@ -558,6 +592,60 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): - `completePrompt` 永远不接受(`/continue` 是 v0.7 新端点,跟 v0.6 没关系)。 - 越界 `iteration`(< 0 / 非整数 / ≥ `maxLoopIterations`)**直接 400 fail-fast**:设计前提是客户端是正常实现,传 999 说明 client 状态坏了,少跑一次多余 LLM 比让 in-loop counter 跑满再吐 LOOP_EXCEEDED 友好。不假设防恶意 client(那是 auth / rate-limit 的事)。 +### 生命周期 hooks `onBeforeLoop` / `onAfterLoop`(0.9.0+) + +在 `onLLMOutput` 这个"per-turn 决策 hook"之外,0.9.0 加了一对**链路级** hook,给"主 LLM loop 跑的同时并行一些副任务(情绪评估、外部 webhook、统计上报…),结束后把结果作为额外 push 追加"这类需求一个干净的口子,不用把副任务塞进 `onLLMOutput` 里跟决策逻辑挤在一起。 + +```ts +createInstantHandler({ + vapid, + onLLMOutput, // 0.7.0+ 老 hook,不变 + onBeforeLoop?: (ctx: { + requestBody: unknown; // 原始请求 body,框架不解析自定义字段 + sessionId: string; + metadata: Record; + }) => unknown | Promise, // 返回值会被 opaque 透传给 onAfterLoop + onAfterLoop?: (ctx: { + deliver: (payload: unknown) => Promise; + sessionId: string; + metadata: Record; + requestBody: unknown; + pending: unknown; // = onBeforeLoop 的返回值 + }) => Promise, +}); +``` + +约定:`onBeforeLoop` 在主 loop 启动前调用,**同步启动副任务、立刻返回 handle 对象**。框架只 `await` 函数返回——不会替你 await 副任务本身。返回值原样进 `onAfterLoop` 的 `pending`。 + +典型用法: + +```js +onBeforeLoop: ({ requestBody }) => ({ + // 这些 promise 立刻就在跑了,跟主 LLM loop 并行 + emotion: runEmotionEval(requestBody), + metrics: pushToAnalytics(requestBody), +}), + +onAfterLoop: async ({ pending, deliver, sessionId }) => { + const { emotion } = pending; + const result = await emotion; // 主 loop 这时已经结束了 + if (result) { + await deliver({ + messageKind: 'emotion_update', + sessionId, + data: result, + }); // 作为额外一条 push 追加到本次链路 + } +}, +``` + +两个 hook 在 SSE 与纯 Push 两种传输模式下**都生效**,`deliver` 抹平差异: + +- SSE 模式:走当前 SSE controller `enqueue` `event: payload`,失败时 fallback Web Push +- 纯 Push 模式:直接 `sendPushWithMaybeBlob` + +所以 hook 作者不用关心调用方走了哪条传输路径。 + ### 事件分类(0.7.0+) 事件统一用**直接 type 名**做 discriminator(不再混 `error+code` 二级嵌套): diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 21ad9c8..2186ea1 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.2", + "version": "0.9.0-next.0", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index bd968e0..a9e049c 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -21,7 +21,8 @@ */ import { validateInstantPayload, validateContinuePayload } from './validation.js'; -import { processInstantMessage } from './message-processor.js'; +import { processInstantMessage, sendPushWithMaybeBlob, ensureStableMessageId } from './message-processor.js'; +import { MESSAGE_TYPE, PUSH_SOURCE, buildErrorPush } from '@rei-standard/amsg-shared'; import { HookError, PayloadTooLargeError, LlmCallError } from './errors.js'; import { utf8, @@ -29,6 +30,7 @@ import { base64UrlToBytes, hmacSha256, timingSafeEqualBytes, + randomUUID, } from './utils.js'; import { DEFAULT_MULTIPART_CHUNK_BYTES, @@ -39,6 +41,14 @@ import { const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// Hot-path SSE encoding helpers. `TextEncoder` is stateless under the +// hood, and the keepalive comment is fixed bytes — encoding either on +// every request / every 15s is pure overhead. Hoist once at module +// load. +const SSE_ENCODER = new TextEncoder(); +const SSE_KEEPALIVE_BYTES = SSE_ENCODER.encode(': keepalive\n\n'); +const SSE_DONE_BYTES = SSE_ENCODER.encode('event: done\ndata: {}\n\n'); + /** * @typedef {Object} VapidConfig * @property {string} email @@ -86,6 +96,10 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * `{ decision: 'continue', nextHistory }` * `{ decision: 'skip-push' }` * See README §Agentic Loop. + * @property {(ctx: { requestBody: unknown, sessionId: string, metadata: Record }) => unknown | Promise} [onBeforeLoop] + * - **v0.9 hook.** Run before the LLM loop starts. Use to launch parallel tasks. + * @property {(ctx: { deliver: (payload: unknown) => Promise, sessionId: string, metadata: Record, requestBody: unknown, pending: unknown }) => Promise} [onAfterLoop] + * - **v0.9 hook.** Run after the LLM loop ends. Use to await parallel tasks and append payloads. * @property {import('./blob-store/interface.js').BlobStoreConfig} [blobStore] * - Optional. When a push payload's * UTF-8 byte length exceeds `maxInlineBytes` (default @@ -157,6 +171,8 @@ export function createInstantHandler(options) { const expectedClientTokenBytes = clientToken ? utf8(clientToken) : null; const corsHeaders = buildCorsHeaders(options.cors); const onLLMOutput = typeof options.onLLMOutput === 'function' ? options.onLLMOutput : null; + const onBeforeLoop = typeof options.onBeforeLoop === 'function' ? options.onBeforeLoop : null; + const onAfterLoop = typeof options.onAfterLoop === 'function' ? options.onAfterLoop : null; const blobStore = options.blobStore || null; const maxLoopIterations = Number.isInteger(options.maxLoopIterations) && options.maxLoopIterations > 0 ? options.maxLoopIterations @@ -295,7 +311,10 @@ export function createInstantHandler(options) { } try { - const work = processInstantMessage(payload, { + const isPurePush = request.headers.get('accept') === 'application/json'; + const sessionId = typeof payload.sessionId === 'string' && payload.sessionId ? payload.sessionId : `sess_${randomUUID()}`; + + const processorCtx = { vapid: options.vapid, fetch: options.fetch || globalThis.fetch, onEvent, @@ -306,21 +325,165 @@ export function createInstantHandler(options) { multipart, requestUrl: request.url, isResume: isContinue, - }); - registerWaitUntil(work, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); - const result = await work; - return respond(200, { success: true, data: result }); + }; + + // Resolve metadata once. `|| {}` would mint two distinct empty + // objects for the two hook calls, so any reference-based + // book-keeping done by onBeforeLoop wouldn't survive into + // onAfterLoop. Sharing one ref also matches caller intuition. + const hookMetadata = payload.metadata || {}; + + // Single lifecycle helper used by both transport modes — keeps + // hook ordering identical regardless of how `deliver` is wired. + const runWithLifecycleHooks = async () => { + let pending; + if (onBeforeLoop) { + pending = await onBeforeLoop({ requestBody: payload, sessionId, metadata: hookMetadata }); + } + const result = await processInstantMessage({ ...payload, sessionId }, processorCtx); + if (onAfterLoop) { + await onAfterLoop({ + deliver: processorCtx.deliver, + sessionId, + metadata: hookMetadata, + requestBody: payload, + pending, + }); + } + return result; + }; + + if (isPurePush) { + processorCtx.deliver = async (pushPayload) => { + await sendPushWithMaybeBlob(ensureStableMessageId(pushPayload), payload, processorCtx, sessionId); + }; + const work = runWithLifecycleHooks(); + registerWaitUntil(work, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); + const result = await work; + return respond(200, { success: true, data: result }); + } + + // SSE Mode — pacing is irrelevant since chunks pipe straight to + // the consumer (no push-gateway rate limit to smooth over). + processorCtx.spacingMs = 0; + + return new Response( + new ReadableStream({ + async start(controller) { + let streamUsable = true; + let keepaliveTimer; + + const onAbort = () => { + streamUsable = false; + if (keepaliveTimer) clearInterval(keepaliveTimer); + }; + request.signal.addEventListener('abort', onAbort); + + const stopKeepalive = () => { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + }; + const startKeepalive = () => { + keepaliveTimer = setInterval(() => { + try { + controller.enqueue(SSE_KEEPALIVE_BYTES); + } catch { + stopKeepalive(); + } + }, 15000); + }; + const safeClose = () => { + // `controller.close()` throws TypeError if the stream is + // already errored (e.g. previous enqueue failed). We're + // exiting anyway — swallow. + try { controller.close(); } catch { /* already closed/errored */ } + }; + const cleanup = () => { + stopKeepalive(); + request.signal.removeEventListener('abort', onAbort); + }; + + // Single transport boundary: try SSE first, fall back to + // Web Push on stream-gone OR enqueue failure. Used for + // both normal `event: payload` and `event: error`. + const safeEnqueue = async (eventName, body, onFallbackFail) => { + const fallback = async () => { + try { + await sendPushWithMaybeBlob(body, payload, processorCtx, sessionId); + } catch (pushErr) { + if (onFallbackFail) onFallbackFail(pushErr); + } + }; + if (!streamUsable) { + await fallback(); + return; + } + try { + controller.enqueue(SSE_ENCODER.encode(`event: ${eventName}\ndata: ${JSON.stringify(body)}\n\n`)); + } catch { + streamUsable = false; + stopKeepalive(); + await fallback(); + } + }; + + processorCtx.deliver = async (pushPayload) => { + await safeEnqueue('payload', ensureStableMessageId(pushPayload)); + }; + startKeepalive(); + + try { + await runWithLifecycleHooks(); + + if (streamUsable) { + try { controller.enqueue(SSE_DONE_BYTES); } catch { /* race with abort */ } + safeClose(); + } + } catch (err) { + // HookError carries an in-loop ErrorPush that already + // shipped via `deliver` (as event: payload) before the + // throw — don't echo a second `event: error` for the + // same logical failure. Other errors (LlmCallError, + // unexpected) had no in-loop diagnostic and DO need one. + if (err instanceof HookError) { + safeClose(); + } else { + const diag = buildErrorPush({ + messageType: MESSAGE_TYPE.INSTANT, + source: PUSH_SOURCE.INSTANT, + messageId: `msg_${randomUUID()}_error`, + sessionId, + code: err?.code || 'INTERNAL_ERROR', + message: err?.message || '内部错误', + timestamp: new Date().toISOString(), + }); + await safeEnqueue('error', diag, (pushErr) => { + onEvent({ type: 'sse_error_fallback_failed', sessionId, cause: pushErr }); + }); + safeClose(); + } + } finally { + cleanup(); + } + } + }), + { + status: 200, + headers: { + ...corsHeaders, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + } + ); + } catch (err) { onEvent({ type: 'error', code: err?.code, message: err?.message }); const code = err?.code || 'INTERNAL_ERROR'; const status = mapErrorStatus(err, code); - // Unified HTTP envelope: every error goes through - // `error: { code, message }` so SDK consumers can always read - // `body.error.code`. The push-payload wire format (what the SW - // receives) is a separate layer — since 0.8.0 it's an ErrorPush - // (`messageKind: 'error'`, `code`, `message`, `iteration?`) built - // via `buildErrorPush(...)`. The pre-0.8.0 `{type:'error', code, - // ...}` envelope is gone — do not look for the `type` field. return respond(status, { success: false, error: { code, message: err?.message || '内部错误' }, diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 42876a0..04f8036 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -16,6 +16,8 @@ */ import { + MESSAGE_TYPE, + PUSH_SOURCE, buildContentPush, buildReasoningPush, buildErrorPush, @@ -41,6 +43,41 @@ const DEFAULT_BLOB_TTL_SECONDS = 60; const VALID_DECISIONS = new Set(['finish', 'tool-request', 'continue', 'skip-push']); const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); +/** + * Stamp a stable `messageId` on the payload if missing. All payloads + * flowing through `deliverPush()` get normalized here so the same id is + * reused for SSE writes and any subsequent Web Push fallback — clients + * dedupe on this id when both transports race. + * + * Idempotent: a payload that already has a non-empty string `messageId` + * is returned unchanged. + * + * @template T + * @param {T} push + * @returns {T} + */ +function ensureStableMessageId(push) { + if (!push || typeof push !== 'object') return push; + const obj = /** @type {{ messageId?: unknown }} */ (push); + if (typeof obj.messageId === 'string' && obj.messageId) return push; + return /** @type {T} */ ({ ...obj, messageId: `msg_${randomUUID()}` }); +} + +async function deliverPush(push, payload, ctx, sessionId) { + if (ctx.deliver) { + // ctx.deliver is the single normalization boundary — both the SSE + // and pure-push handler variants stamp `messageId` at entry, and + // hook authors calling `deliver` directly from onAfterLoop go + // through the same path. Don't double-normalize here. + await ctx.deliver(push); + } else { + // Fallback: external callers using `processInstantMessage` directly + // without wiring up a `ctx.deliver` still need a stable id before + // the payload reaches the transport. + await sendPushWithMaybeBlob(ensureStableMessageId(push), payload, ctx, sessionId); + } +} + /** * Deliver `pushPayloads` sequentially via `sendPushWithMaybeBlob`, * spacing `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between consecutive @@ -49,12 +86,14 @@ const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); * * Per-push auto-fill (copies each hook-returned object before enriching * the transport payload): - * - `messageId` — only when the hook didn't set one (auto-fill - * with `msg__chunk_` so deduplication - * on the SW side works across retries). * - `messageIndex` — always overwritten (1-based) with the array index. * - `totalMessages` — always overwritten with `pushPayloads.length`. * + * `messageId` is NOT stamped here — `deliverPush()` runs + * `ensureStableMessageId()` on every payload that crosses transport, + * so a missing id is filled in once (and the same id is reused if the + * SSE write fails and falls back to Web Push). + * * Throws on the first failed push; subsequent pushes are not attempted. * Callers decide whether to surface the throw or treat the partial * delivery as best-effort. @@ -68,15 +107,18 @@ const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); */ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sleep) { const total = pushPayloads.length; + // Spacing is a Web Push concern (gateway rate-limit smoothing + chat + // UX pacing). SSE responses pipe straight to the consumer, so the + // SSE handler sets `ctx.spacingMs = 0` to ship the burst immediately. + const spacingMs = Number.isFinite(ctx.spacingMs) && ctx.spacingMs >= 0 + ? ctx.spacingMs + : SLEEP_BETWEEN_MESSAGES_MS; for (let i = 0; i < total; i++) { const push = { ...pushPayloads[i] }; - if (push.messageId === undefined) { - push.messageId = `msg_${randomUUID()}_chunk_${i}`; - } push.messageIndex = i + 1; push.totalMessages = total; try { - await sendPushWithMaybeBlob(push, payload, ctx, sessionId); + await deliverPush(push, payload, ctx, sessionId); } catch (err) { // HookError / PayloadTooLargeError already carry their own .code and // should propagate unwrapped — those are caller-shape contract @@ -91,8 +133,8 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle wrapped.cause = err; throw wrapped; } - if (i < total - 1) { - await sleep(SLEEP_BETWEEN_MESSAGES_MS); + if (spacingMs > 0 && i < total - 1) { + await sleep(spacingMs); } } return total; @@ -113,7 +155,7 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle * @returns {Promise} Total leaves shipped. */ async function emitReasoning(reasoningPush, payload, ctx, sessionId) { - await sendPushWithMaybeBlob(reasoningPush, payload, ctx, sessionId); + await deliverPush(reasoningPush, payload, ctx, sessionId); return 1; } @@ -356,6 +398,10 @@ async function runLegacyInstant(payload, ctx) { const fetchImpl = ctx.fetch || globalThis.fetch; const sleep = ctx.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; + // SSE mode passes `spacingMs: 0` — pacing is a Web Push concern. + const spacingMs = Number.isFinite(ctx.spacingMs) && ctx.spacingMs >= 0 + ? ctx.spacingMs + : SLEEP_BETWEEN_MESSAGES_MS; // sessionId is shared across all pushes from this legacy invocation: // an optional ReasoningPush + N ContentPush sentences. Callers can @@ -388,8 +434,8 @@ async function runLegacyInstant(payload, ctx) { const reasoning = readReasoningContent(llmResponse); if (reasoning) { const reasoningPush = buildReasoningPush({ - messageType: 'instant', - source: 'instant', + messageType: MESSAGE_TYPE.INSTANT, + source: PUSH_SOURCE.INSTANT, messageId: `msg_${randomUUID()}_instant_reasoning`, sessionId, reasoningContent: reasoning, @@ -418,10 +464,11 @@ async function runLegacyInstant(payload, ctx) { } // Only space the burst when the reasoning push actually shipped — - // skipping the sleep when it failed shaves 1.5s off the perceived - // latency for that case. - if (reasoningShipped) { - await sleep(SLEEP_BETWEEN_MESSAGES_MS); + // skipping the sleep when it failed shaves the gap off perceived + // latency for that case. SSE callers set spacingMs=0 to skip + // entirely (no gateway to smooth for). + if (reasoningShipped && spacingMs > 0) { + await sleep(spacingMs); } } @@ -444,8 +491,8 @@ async function runLegacyInstant(payload, ctx) { for (let i = 0; i < messages.length; i++) { const contentPush = buildContentPush({ - messageType: 'instant', - source: 'instant', + messageType: MESSAGE_TYPE.INSTANT, + source: PUSH_SOURCE.INSTANT, messageId: `msg_${randomUUID()}_instant_${i}`, sessionId, message: messages[i], @@ -461,7 +508,7 @@ async function runLegacyInstant(payload, ctx) { }); try { - await sendPushWithMaybeBlob(contentPush, payload, ctx, sessionId); + await deliverPush(contentPush, payload, ctx, sessionId); onEvent({ type: 'push_sent', messageIndex: i + 1, totalMessages: messages.length, sessionId }); } catch (err) { if (err && err.code === 'PAYLOAD_TOO_LARGE') throw err; @@ -472,8 +519,8 @@ async function runLegacyInstant(payload, ctx) { throw error; } - if (i < messages.length - 1) { - await sleep(SLEEP_BETWEEN_MESSAGES_MS); + if (spacingMs > 0 && i < messages.length - 1) { + await sleep(spacingMs); } } @@ -547,8 +594,8 @@ async function runAgenticLoop(payload, ctx) { const reasoning = readReasoningContent(llmResponse); if (reasoning) { const reasoningPush = buildReasoningPush({ - messageType: 'instant', - source: 'instant', + messageType: MESSAGE_TYPE.INSTANT, + source: PUSH_SOURCE.INSTANT, messageId: `msg_${randomUUID()}_iter_${iteration}_reasoning`, sessionId, reasoningContent: reasoning, @@ -589,8 +636,8 @@ async function runAgenticLoop(payload, ctx) { } catch (err) { onEvent({ type: 'hook_threw', sessionId, iteration, cause: err }); const diagnostic = buildErrorPush({ - messageType: 'instant', - source: 'instant', + messageType: MESSAGE_TYPE.INSTANT, + source: PUSH_SOURCE.INSTANT, messageId: `msg_${randomUUID()}_iter_${iteration}_error`, sessionId, code: 'HOOK_THREW', @@ -599,7 +646,7 @@ async function runAgenticLoop(payload, ctx) { timestamp: new Date().toISOString(), }); try { - await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); + await deliverPush(diagnostic, payload, ctx, sessionId); } catch (pushErr) { onEvent({ type: 'diagnostic_push_failed', code: 'HOOK_THREW', sessionId, cause: pushErr }); } @@ -659,7 +706,7 @@ async function runAgenticLoop(payload, ctx) { try { // The diagnostic is a single push by construction (one // `buildErrorPush(...)` call above); no looping needed. - await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); + await deliverPush(diagnostic, payload, ctx, sessionId); } catch (err) { onEvent({ type: 'diagnostic_push_failed', code: 'LOOP_EXCEEDED', sessionId, cause: err }); } @@ -978,4 +1025,4 @@ function buildBlobUrl(requestUrl, key) { return `/blob/${key}`; } -export { sendPushWithMaybeBlob, readReasoningContent }; +export { sendPushWithMaybeBlob, readReasoningContent, ensureStableMessageId }; diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 598c5dd..9ef286c 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -37,6 +37,7 @@ import { createFetchRouter, decryptCapturedPushBody, base64UrlToBytes, + consumeSse, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -74,7 +75,20 @@ function basePayload(overrides = {}) { }; } +// All existing tests in this file pre-date the SSE-default transport +// and assert on the JSON response body (`res.json()` / `res.status`). +// To keep that coverage intact, this helper opts the request out into +// pure-push mode (`Accept: application/json`) by default. New SSE-mode +// tests build their request via `makeSseRequest()` instead. function makeRequest(url, body, headers = {}) { + return new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +function makeSseRequest(url, body, headers = {}) { return new Request(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, @@ -131,7 +145,8 @@ describe('agentic loop — decision: finish', () => { // sendPushesSequentially auto-fills these on every push. assert.equal(decoded.messageIndex, 1); assert.equal(decoded.totalMessages, 1); - assert.match(decoded.messageId, /^msg_[0-9a-f-]+_chunk_0$/); + // ensureStableMessageId stamps `msg_` when the hook didn't set one. + assert.match(decoded.messageId, /^msg_[0-9a-f-]+$/); }); }); @@ -373,14 +388,14 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { }); const blobAdapter = createMemoryBlobStore(); // Build a JSON string of *exactly* `len` UTF-8 bytes after the - // sendPushesSequentially auto-fill enriches the transport copy. - // Final shape: {"type":"x","p":"...","messageId":"msg__chunk_0","messageIndex":1,"totalMessages":1} - // messageId is the only variable-width field; its UUID is 36 chars, - // so messageId value length is `msg_`.length + 36 + `_chunk_0`.length = 48. + // transport auto-fill enriches the payload. + // Final shape: {"type":"x","p":"...","messageId":"msg_","messageIndex":1,"totalMessages":1} + // messageId is the only variable-width field; ensureStableMessageId + // stamps `msg_` so the value length is `msg_`.length + 36 = 40. const overhead = JSON.stringify({ type: 'x', p: '', - messageId: 'm'.repeat(48), + messageId: 'm'.repeat(40), messageIndex: 1, totalMessages: 1, }).length; @@ -962,3 +977,210 @@ describe('0.8.0 — pushPayloads happy paths', () => { assert.equal(events.some(e => e.type === 'final_pushed'), false, 'no final_pushed on partial delivery'); }); }); + +// ─── SSE transport (default mode) ─────────────────────────────────────── +// +// Mirror tests that exercise each agentic-loop decision branch through +// the default SSE transport. The pure-push opt-out coverage for the same +// scenarios lives in the describe blocks above (their local `makeRequest` +// stamps `Accept: application/json`). + +describe('agentic loop — SSE: decision: finish', () => { + it('streams the hook-returned payload as event: payload and ends with event: done', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hi there'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: (ctx) => ({ + decision: 'finish', + pushPayloads: [{ type: 'custom', text: ctx.llmOutputText }], + }), + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + const { payloads, errors, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(errors.length, 0); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].type, 'custom'); + assert.equal(payloads[0].text, 'hi there'); + assert.equal(payloads[0].messageIndex, 1); + assert.equal(payloads[0].totalMessages, 1); + assert.match(payloads[0].messageId, /^msg_[0-9a-f-]+$/); + // SSE direct delivery — no Web Push fallback. + assert.equal(router.pushCalls.length, 0); + }); +}); + +describe('agentic loop — SSE: decision: tool-request', () => { + it('streams the tool-request payload', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('NEED_TOOL get_weather'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ + decision: 'tool-request', + pushPayloads: [{ type: 'tool-request', tool: 'get_weather' }], + }), + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].type, 'tool-request'); + assert.equal(payloads[0].tool, 'get_weather'); + assert.equal(router.pushCalls.length, 0); + }); +}); + +describe('agentic loop — SSE: decision: continue → finish', () => { + it('loops once then streams finish payload via SSE', async () => { + let llmCalls = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse(`round-${++llmCalls}`), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: (ctx) => { + if (ctx.iteration === 0) { + return { + decision: 'continue', + nextHistory: [ + ...ctx.messages, + { role: 'user', content: 'reflect again' }, + ], + }; + } + return { decision: 'finish', pushPayloads: [{ type: 'done' }] }; + }, + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(llmCalls, 2); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].type, 'done'); + assert.equal(router.pushCalls.length, 0); + }); +}); + +describe('agentic loop — SSE: decision: skip-push', () => { + it('emits no payload, closes with event: done', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('nothing to push'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + const { payloads, errors, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 0); + assert.equal(errors.length, 0); + assert.equal(router.pushCalls.length, 0); + }); +}); + +describe('agentic loop — SSE: loop-exceeded', () => { + it('streams a LOOP_EXCEEDED ErrorPush as event: payload', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('again'), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + maxLoopIterations: 3, + onLLMOutput: (ctx) => ({ decision: 'continue', nextHistory: ctx.messages }), + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + assert.equal(res.status, 200); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].messageKind, 'error'); + assert.equal(payloads[0].code, 'LOOP_EXCEEDED'); + assert.ok(events.find((e) => e.type === 'loop_exceeded')); + assert.equal(router.pushCalls.length, 0); + }); +}); + +describe('agentic loop — SSE: hook contract violations', () => { + it('hook throw → in-loop diagnostic as event: payload, no duplicate event: error', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('boom'), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + onLLMOutput: () => { throw new Error('hook intentional fail'); }, + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + // SSE always responds 200 — the error rides on the stream. + assert.equal(res.status, 200); + const { payloads, errors } = await consumeSse(res); + // The in-loop diagnostic flows through deliver as event: payload … + assert.equal(payloads.length, 1); + assert.equal(payloads[0].messageKind, 'error'); + assert.equal(payloads[0].code, 'HOOK_THREW'); + // … and the outer HookError re-throw is intentionally NOT echoed + // as event: error — the diagnostic already shipped, second copy + // would just double-trigger error handlers downstream. + assert.equal(errors.length, 0); + assert.ok(events.find((e) => e.type === 'hook_threw')); + assert.equal(router.pushCalls.length, 0); + }); + + it('hook returns null → in-loop diagnostic only, no event: error', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => null, + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + assert.equal(res.status, 200); + const { payloads, errors } = await consumeSse(res); + assert.equal(errors.length, 0); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].code, 'HOOK_THREW'); + }); + + it('hook returns unknown decision tag → in-loop diagnostic only, no event: error', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'bogus' }), + }); + const res = await handler(makeSseRequest('http://h/instant', basePayload())); + assert.equal(res.status, 200); + const { payloads, errors } = await consumeSse(res); + assert.equal(errors.length, 0); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].code, 'HOOK_THREW'); + }); +}); diff --git a/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs index 1ada82c..16fda08 100644 --- a/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs +++ b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs @@ -24,10 +24,13 @@ before(async () => { subKit = await generateTestSubscription(); }); +// waitUntil lifecycle wiring is exercised on the pure-push opt-out path — +// the SSE branch keeps the response stream open by design and does not +// register a separate background work item. function makeRequest(body) { return new Request('https://worker.example.com/instant', { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), }); } @@ -211,6 +214,7 @@ function makeNodeRequestResponse(body) { req.headers = { host: 'localhost', 'content-type': 'application/json', + accept: 'application/json', 'content-length': String(Buffer.byteLength(rawBody)), }; req.socket = {}; diff --git a/packages/rei-standard-amsg/instant/test/e2e.test.mjs b/packages/rei-standard-amsg/instant/test/e2e.test.mjs index 9437d94..37f2798 100644 --- a/packages/rei-standard-amsg/instant/test/e2e.test.mjs +++ b/packages/rei-standard-amsg/instant/test/e2e.test.mjs @@ -23,6 +23,7 @@ import { createFetchRouter, decryptCapturedPushBody, makeLlmResponse, + consumeSse, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -36,8 +37,8 @@ before(async () => { }); describe('e2e: push payload contract parity with amsg-server', () => { - it('produces a ContentPush carrying all 13 legacy fields + messageKind + sessionId', async () => { - const payload = { + function buildPayload() { + return { contactName: '小手机', avatarUrl: 'https://example.com/avatar.png', completePrompt: 'reply with two sentences in Chinese', @@ -48,6 +49,10 @@ describe('e2e: push payload contract parity with amsg-server', () => { pushSubscription: subKit.subscription, metadata: { foo: 'bar', n: 42 }, }; + } + + it('opt-out (Accept: application/json): produces a ContentPush carrying all 13 legacy fields + messageKind + sessionId', async () => { + const payload = buildPayload(); const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, @@ -57,7 +62,7 @@ describe('e2e: push payload contract parity with amsg-server', () => { const handler = createInstantHandler({ vapid, fetch: router.fetch }); const req = new Request('http://localhost/instant', { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(payload), }); const res = await handler(req); @@ -117,4 +122,42 @@ describe('e2e: push payload contract parity with amsg-server', () => { // "one LLM round → one sessionId" invariant from the shared schema. assert.equal(captured[0].sessionId, captured[1].sessionId); }); + + it('SSE (default): streams the same ContentPush field shape over event: payload, no Web Push', async () => { + const payload = buildPayload(); + + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: async () => makeLlmResponse('第一句。第二句!'), + }); + + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const req = new Request('http://localhost/instant', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + const res = await handler(req); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 2); + assert.equal(router.pushCalls.length, 0, 'SSE happy path must not fall back to Web Push'); + + const required = [ + 'title', 'message', 'contactName', 'messageId', 'messageIndex', + 'totalMessages', 'messageType', 'messageSubtype', 'taskId', + 'timestamp', 'source', 'avatarUrl', 'metadata', + 'messageKind', 'sessionId', + ]; + for (const field of required) { + assert.ok(field in payloads[0], `payload missing field: ${field}`); + } + assert.equal(payloads[0].messageKind, 'content'); + assert.equal(payloads[0].message, '第一句。'); + assert.equal(payloads[1].message, '第二句!'); + assert.equal(payloads[0].sessionId, payloads[1].sessionId); + }); }); diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index bfe0f27..9eb28ff 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -9,9 +9,13 @@ import { generateTestVapid, generateTestSubscription, createFetchRouter, + decryptCapturedPushBody, makeLlmResponse, + consumeSse, } from './helpers.mjs'; +const ACCEPT_JSON = { accept: 'application/json' }; + const LLM_URL = 'https://api.example.com/v1/chat/completions'; let vapid; @@ -381,14 +385,27 @@ describe('createInstantHandler — request validation', () => { // ─── Handler: clientToken weak auth ──────────────────────────────────── describe('createInstantHandler — clientToken', () => { - it('passes through when clientToken is not configured (open mode)', async () => { + it('opt-out (Accept: application/json): passes through when clientToken is not configured (open mode)', async () => { const router = llmRouter('hi.'); const handler = createInstantHandler({ vapid, fetch: router.fetch }); - const res = await handler(makeRequest({ body: makeValidPayload() })); + const res = await handler(makeRequest({ body: makeValidPayload(), headers: ACCEPT_JSON })); assert.equal(res.status, 200); assert.equal(router.pushCalls.length, 1); }); + it('SSE (default): passes through when clientToken is not configured (open mode)', async () => { + const router = llmRouter('hi.'); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const res = await handler(makeRequest({ body: makeValidPayload() })); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(payloads.length, 1); + assert.equal(doneReceived, true); + // SSE happy path must not fall back to Web Push. + assert.equal(router.pushCalls.length, 0); + }); + it('returns 401 INVALID_CLIENT_TOKEN when header missing', async () => { const handler = createInstantHandler({ vapid, clientToken: 'shared-secret-xyz' }); const res = await handler(makeRequest({ body: makeValidPayload() })); @@ -408,7 +425,7 @@ describe('createInstantHandler — clientToken', () => { assert.equal(body.error.code, 'INVALID_CLIENT_TOKEN'); }); - it('returns 200 when header matches clientToken', async () => { + it('opt-out (Accept: application/json): returns 200 when header matches clientToken', async () => { const router = llmRouter('matched.'); const handler = createInstantHandler({ vapid, @@ -417,21 +434,40 @@ describe('createInstantHandler — clientToken', () => { }); const res = await handler(makeRequest({ body: makeValidPayload(), - headers: { 'x-client-token': 'shared-secret-xyz' }, + headers: { 'x-client-token': 'shared-secret-xyz', ...ACCEPT_JSON }, })); assert.equal(res.status, 200); assert.equal(router.pushCalls.length, 1); }); + + it('SSE (default): returns 200 stream when header matches clientToken', async () => { + const router = llmRouter('matched.'); + const handler = createInstantHandler({ + vapid, + clientToken: 'shared-secret-xyz', + fetch: router.fetch, + }); + const res = await handler(makeRequest({ + body: makeValidPayload(), + headers: { 'x-client-token': 'shared-secret-xyz' }, + })); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(payloads.length, 1); + assert.equal(doneReceived, true); + assert.equal(router.pushCalls.length, 0); + }); }); // ─── Handler: happy path & push delivery ────────────────────────────── describe('createInstantHandler — happy path', () => { - it('parses plaintext, calls LLM, splits, pushes each sentence, returns 200', async () => { + it('opt-out (Accept: application/json): parses plaintext, calls LLM, splits, pushes each sentence, returns 200', async () => { const router = llmRouter('你好。今天好天气!'); const handler = createInstantHandler({ vapid, fetch: router.fetch }); - const res = await handler(makeRequest({ body: makeValidPayload() })); + const res = await handler(makeRequest({ body: makeValidPayload(), headers: ACCEPT_JSON })); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.success, true); @@ -447,32 +483,90 @@ describe('createInstantHandler — happy path', () => { } }); - it('returns LLM_CALL_FAILED on upstream error', async () => { + it('SSE (default): parses plaintext, calls LLM, splits, streams each sentence as event: payload, no Web Push', async () => { + const router = llmRouter('你好。今天好天气!'); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 2); + assert.equal(payloads[0].messageKind, 'content'); + assert.equal(payloads[0].message, '你好。'); + assert.equal(payloads[0].messageIndex, 1); + assert.equal(payloads[1].message, '今天好天气!'); + assert.equal(payloads[1].messageIndex, 2); + // Same sessionId across the stream. + assert.equal(payloads[0].sessionId, payloads[1].sessionId); + // SSE direct delivery — no Web Push fallback hit. + assert.equal(router.pushCalls.length, 0); + }); + + it('opt-out (Accept: application/json): returns LLM_CALL_FAILED on upstream error', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: async () => ({ ok: false, status: 500, statusText: 'oops' }), }); const handler = createInstantHandler({ vapid, fetch: router.fetch }); - const res = await handler(makeRequest({ body: makeValidPayload() })); + const res = await handler(makeRequest({ body: makeValidPayload(), headers: ACCEPT_JSON })); assert.equal(res.status, 502); const body = await res.json(); assert.equal(body.error.code, 'LLM_CALL_FAILED'); assert.equal(router.pushCalls.length, 0); }); - it('returns PUSH_SEND_FAILED when push gateway returns non-2xx', async () => { + it('SSE (default): emits event: error with LLM_CALL_FAILED on upstream error', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: async () => ({ ok: false, status: 500, statusText: 'oops' }), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const res = await handler(makeRequest({ body: makeValidPayload() })); + // SSE responses always 200 even on business errors — the error rides as event: error. + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + const { payloads, errors } = await consumeSse(res); + assert.equal(payloads.length, 0); + assert.equal(errors.length, 1); + assert.equal(errors[0].code, 'LLM_CALL_FAILED'); + assert.equal(errors[0].messageKind, 'error'); + assert.equal(router.pushCalls.length, 0); + }); + + it('opt-out (Accept: application/json): returns PUSH_SEND_FAILED when push gateway returns non-2xx', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: async () => makeLlmResponse('one sentence'), onPush: () => new Response('gone', { status: 410, statusText: 'Gone' }), }); const handler = createInstantHandler({ vapid, fetch: router.fetch }); - const res = await handler(makeRequest({ body: makeValidPayload() })); + const res = await handler(makeRequest({ body: makeValidPayload(), headers: ACCEPT_JSON })); assert.equal(res.status, 502); const body = await res.json(); assert.equal(body.error.code, 'PUSH_SEND_FAILED'); }); + it('SSE (default): push gateway failure is irrelevant — SSE writes succeed without fallback', async () => { + // In SSE happy path, the push gateway is never touched. We still wire + // it up as an always-failing endpoint to prove the handler does not + // silently fall back; the test asserts zero push calls AND a clean stream. + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: async () => makeLlmResponse('one sentence'), + onPush: () => new Response('gone', { status: 410, statusText: 'Gone' }), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const res = await handler(makeRequest({ body: makeValidPayload() })); + assert.equal(res.status, 200); + const { payloads, errors, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 1); + assert.equal(errors.length, 0); + assert.equal(router.pushCalls.length, 0); + }); + it('rejects request when tokenSigningKey is set but Authorization missing', async () => { const handler = createInstantHandler({ vapid, tokenSigningKey: 'signing-secret' }); const res = await handler(makeRequest({ body: makeValidPayload() })); diff --git a/packages/rei-standard-amsg/instant/test/helpers.mjs b/packages/rei-standard-amsg/instant/test/helpers.mjs index c944133..22acfd4 100644 --- a/packages/rei-standard-amsg/instant/test/helpers.mjs +++ b/packages/rei-standard-amsg/instant/test/helpers.mjs @@ -201,4 +201,102 @@ export function makeLlmResponse(content, extra = {}) { }; } +/** + * Build a Fetch `Request` for handler tests. `mode` controls the + * `Accept` header: `'pure-push'` (default) stamps `application/json` + * so the handler takes the opt-out path; `'sse'` leaves Accept absent + * so the handler takes the default SSE path. Centralised here so test + * files don't each re-implement the same builder. + * + * @param {{ + * url?: string, + * method?: string, + * body?: unknown, + * headers?: Record, + * mode?: 'pure-push' | 'sse', + * }} [opts] + * @returns {Request} + */ +export function buildHandlerRequest({ + url = 'http://localhost/instant', + method = 'POST', + body, + headers = {}, + mode = 'pure-push', +} = {}) { + const merged = { 'content-type': 'application/json', ...headers }; + if (mode === 'pure-push' && merged.accept === undefined) { + merged.accept = 'application/json'; + } + return new Request(url, { + method, + headers: merged, + body: body === undefined + ? undefined + : (typeof body === 'string' ? body : JSON.stringify(body)), + }); +} + +/** + * Drain an SSE response and return the parsed events. Used by handler + * tests that exercise the default (SSE) transport path. + * + * Returns `{ payloads, errors, doneReceived }`: + * - `payloads`: every `event: payload` (JSON-parsed) in arrival order + * - `errors`: every `event: error` (JSON-parsed) in arrival order + * - `doneReceived`: whether `event: done` arrived before stream EOF + * + * Keepalive comment lines (`:`-prefixed) are skipped silently. + * + * @param {Response} res + * @returns {Promise<{ payloads: Array, errors: Array, doneReceived: boolean }>} + */ +export async function consumeSse(res) { + const payloads = []; + const errors = []; + let doneReceived = false; + if (!res.body) return { payloads, errors, doneReceived }; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + for (const frame of frames) { + if (!frame.trim()) continue; + let event = 'message'; + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith(':')) continue; + if (line.startsWith('event:')) { + event = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + // SSE spec: multi-line `data:` concatenates with `\n`. + const piece = line.slice(5).trim(); + data = data ? `${data}\n${piece}` : piece; + } + } + if (event === 'done') { + doneReceived = true; + return { payloads, errors, doneReceived }; + } + if (event === 'error' && data) { + try { errors.push(JSON.parse(data)); } catch { /* ignore */ } + continue; + } + if (event === 'payload' && data) { + try { payloads.push(JSON.parse(data)); } catch { /* ignore */ } + } + } + } + } finally { + try { reader.releaseLock(); } catch { /* ignore */ } + } + return { payloads, errors, doneReceived }; +} + export { bytesToBase64Url, base64UrlToBytes }; diff --git a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs index e98ec19..5494c91 100644 --- a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs +++ b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs @@ -36,10 +36,13 @@ function basePayload(overrides = {}) { }; } +// Existing tests in this file assert on the Web Push wire shape — keep +// them on the pure-push opt-out path. SSE-mode coverage lives in +// agentic-loop.test.mjs and handler.test.mjs. function makeRequest(url, body, headers = {}) { return new Request(url, { method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, + headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, body: JSON.stringify(body), }); } diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs index 2f21362..cf84919 100644 --- a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -29,10 +29,14 @@ before(async () => { subKit = await generateTestSubscription(); }); +// Existing tests in this file assert on the Web Push wire shape — keep +// them on the pure-push opt-out path by stamping `Accept: application/json` +// here. SSE-mode reasoning behavior is covered separately in +// agentic-loop.test.mjs and handler.test.mjs. function makeRequest(body, headers = {}) { return new Request('http://localhost/instant', { method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, + headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, body: JSON.stringify(body), }); } From 655ac03ff1fc989488ab45945e7d33085b377b04 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Sat, 30 May 2026 19:11:21 +0800 Subject: [PATCH 2/9] fix(amsg-instant): wire SSE branch into waitUntil lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9.0-next.0 introduced the SSE-default transport but did not register the async tail of `ReadableStream.start()` with `ctx.waitUntil`. On runtimes like Cloudflare Workers that reclaim the isolate once the response stream stops emitting bytes, this left the post-disconnect fallback path — `await sendPushWithMaybeBlob(...)` against the push gateway — at risk of being killed mid-flight, even though the rest of the framework treats that fallback as the documented best-effort recovery for client disconnect. Wire a `startDone` deferred that the runtime adapter can attach via `ctx.waitUntil`; `start()`'s finally resolves it after cleanup, so the isolate stays alive long enough for the in-progress fallback fetch to complete. Actual window is bounded by the runtime / plan budget, not the framework — CHANGELOG and README intentionally avoid quoting any specific seconds. Adds a cloudflare-adapter.test.mjs case that asserts SSE responses register exactly one waitUntil and that the deferred resolves once the stream drains. Full Miniflare/workerd disconnect-path verification remains future work. Tests: 172/172 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 20 ++++++++++ packages/rei-standard-amsg/instant/README.md | 5 +++ .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/instant/src/index.js | 14 +++++++ .../instant/test/cloudflare-adapter.test.mjs | 39 +++++++++++++++++-- 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 449beb7..6452f7a 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog — @rei-standard/amsg-instant +## 0.9.0-next.1 — SSE 分支接入 waitUntil (pre-release) + +修补 `0.9.0-next.0` SSE 分支没接生命周期保护的遗漏。 + +**问题**:SSE 模式下 handler 同步 `return Response`,`ReadableStream.start()` 在后台跑。客户端断开后流不再写字节,fallback 路径里 `await sendPushWithMaybeBlob(...)`(发 HTTP 给 push gateway)失去 runtime 的"还在干活"信号,部分 runtime(典型如 Cloudflare Workers)可能在这一步中途回收 isolate,导致 fallback push **服务端代码没机会跑完**。这是 plan §"环境约束"已经预警过的脆弱点,但 `0.9.0-next.0` 的实现没盖到。 + +**修复**:SSE 分支也走 `registerWaitUntil`——`start()` 返回前注册一个 deferred,`start()` 的 `finally` 里 resolve。runtime 看到 unresolved promise 就不会先掐 isolate,fallback HTTP 调用得以完整发出。 + +**保证范围**(去掉网络 / push 服务 / 设备 / SW 这些不可控因素后): + +- 客户端断在 LLM 调用期间 → LLM 跑完 + 所有 push 全走 fallback 发完 +- 客户端断在第 N 条 SSE push 之后 → 第 N+1 条起的 fallback push 完整发出 + +**不保证**: + +- 实际可跑时长**受所在 runtime / 计划档位的 `waitUntil` 与 CPU/wall 上限约束**——不同平台、不同付费层级窗口不一样,本包不承诺具体数字 +- `controller.enqueue()` 返回成功但客户端还没读完那部分字节、随后断开 → 服务端以为送达了不会触发 fallback。修复严格 at-least-once 需要 ACK + replay buffer,本版仍不引入 + +非 SSE 分支与 `0.9.0-next.0` 一致。 + ## 0.9.0-next.0 — SSE 流式传输 + 生命周期 hooks (pre-release) 发布在 `next` dist-tag。**不要在下游 SSE consumer(`@rei-standard/amsg-client@2.4.0-next.0+` 的 `consumeInstantStream`)接入完成前升级到 latest。** diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 45581d1..42260e9 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -993,6 +993,11 @@ export default createCloudflareWorker((env) => ({ `createInstantHandler(...)` 挂成 Worker module `fetch` 也支持同样的 `(request, env, ctx)` 形态。 +> **0.9.0+ 默认 SSE 模式同样接入 `waitUntil`**:客户端中途断开后,剩余 payload 的 +> Web Push fallback HTTP 调用也由 `ctx.waitUntil` 保护,runtime 不会在 `fetch(pushService)` +> 还在 await 的时候回收 isolate。实际可跑窗口受所在 runtime 与计划档位的 +> `waitUntil` / CPU / wall 上限约束。 + ```toml # wrangler.toml name = "amsg-instant" diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 2186ea1..a913875 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.9.0-next.0", + "version": "0.9.0-next.1", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index a9e049c..16093a6 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -367,6 +367,19 @@ export function createInstantHandler(options) { // the consumer (no push-gateway rate limit to smooth over). processorCtx.spacingMs = 0; + // Lifecycle protection for the async tail of `start()`. After + // client disconnect the SSE controller stops emitting bytes, so + // runtimes like Cloudflare Workers lose their "request is alive" + // signal and may reclaim the isolate mid-fallback (the `await + // fetch(pushService)` inside `sendPushWithMaybeBlob`). Register + // a deferred that resolves only after `start()`'s `finally` runs + // — the runtime then keeps the isolate alive for the fallback + // push HTTP call to actually complete. Subject to the runtime / + // plan's own `waitUntil` and CPU/wall budget. + let resolveStartDone; + const startDone = new Promise((resolve) => { resolveStartDone = resolve; }); + registerWaitUntil(startDone, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); + return new Response( new ReadableStream({ async start(controller) { @@ -466,6 +479,7 @@ export function createInstantHandler(options) { } } finally { cleanup(); + resolveStartDone(); } } }), diff --git a/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs index 16fda08..c9dcee9 100644 --- a/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs +++ b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs @@ -24,9 +24,9 @@ before(async () => { subKit = await generateTestSubscription(); }); -// waitUntil lifecycle wiring is exercised on the pure-push opt-out path — -// the SSE branch keeps the response stream open by design and does not -// register a separate background work item. +// Default `makeRequest` opts into pure-push so the existing assertions +// continue to read `messagesSent` from the JSON response body. SSE-mode +// waitUntil coverage uses `makeSseRequest` below. function makeRequest(body) { return new Request('https://worker.example.com/instant', { method: 'POST', @@ -35,6 +35,14 @@ function makeRequest(body) { }); } +function makeSseRequest(body) { + return new Request('https://worker.example.com/instant', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + function makePayload() { return { contactName: 'Rei', @@ -131,6 +139,31 @@ describe('Cloudflare waitUntil lifecycle', () => { assert.equal(waitUntilPromises.length, 1); assert.equal((await waitUntilPromises[0]).messagesSent, 1); }); + + it('SSE mode also registers a waitUntil deferred so post-disconnect fallback can finish', async () => { + // After a client disconnect the SSE stream stops emitting bytes, so + // CF runtime needs an explicit waitUntil to keep the isolate alive + // for the fallback Web Push HTTP call. This test just verifies the + // wiring — actual disconnect behaviour needs Miniflare to exercise. + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + + const res = await handler(makeSseRequest(makePayload()), {}, { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') || '', /text\/event-stream/); + assert.equal(waitUntilPromises.length, 1, 'SSE branch must register a waitUntil'); + + // Drain the SSE response so start()'s finally runs and the deferred resolves. + const reader = res.body.getReader(); + while (!(await reader.read()).done) { /* drain */ } + await assert.doesNotReject(waitUntilPromises[0]); + }); }); describe('adapter waitUntil lifecycle', () => { From b0a18621429f99ab5bec907e0572db0c10b73c09 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:17:34 +0800 Subject: [PATCH 3/9] feat(amsg-shared): add notification.silent and complete NotificationDirective typedef - Add silent? boolean field; validate in buildContentPush / buildToolRequestPush. - Typedef now acknowledges per-field top-level fallback for tag / renotify / requireInteraction / silent / data, matching what amsg-sw has always done at runtime. - README gains a Notification directive section with per-field reference table. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/shared/CHANGELOG.md | 16 ++++++- packages/rei-standard-amsg/shared/README.md | 24 ++++++++++ .../rei-standard-amsg/shared/src/index.js | 48 ++++++++++--------- .../shared/test/builders.test.mjs | 13 ++--- 4 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index 8304615..2580d5e 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,5 +1,19 @@ # @rei-standard/amsg-shared +## 0.2.0 — Notification silent support + +### New + +- **NotificationDirective**:新增并校验 `notification.silent?: boolean`,与 `@rei-standard/amsg-sw@2.2.0` 的无声通知渲染能力对齐。 + +### Fix + +- **NotificationDirective typedef 与 SW 实际行为对齐**:原 typedef 写 `tag` / `renotify` / `requireInteraction` / `silent` 没有 top-level fallback,实际上 `amsg-sw` 一直对这四个字段(以及 `data`)都按 `notification.X` → `payload.X` → 默认值的顺序回退。typedef 改成承认完整 fallback,避免 producer 误以为漏在 payload 顶级的字段不生效。仅 doc / type,wire format 不变。 + +### Compatibility + +- 纯 additive。未传 `notification.silent` 时 wire format 不变。 + ## 0.1.0 — NotificationDirective 与 Shared utilities ### New @@ -20,7 +34,7 @@ Coordinated with `@rei-standard/amsg-instant@0.8.0-next.3`. Install with `npm in ### 为什么 typed 全部 7 个字段(而不只是 `title` / `body`) -最早的 follow-up 草稿写的是 `{ title?: string; body?: string }`。但 SW 实际读 7 个,只 typed 2 个会让另外 5 个仍然走 untyped spread——recreate next.3 amsg-instant 那个 footgun 的根本原因(typedef 不全 → spread bypass → 行为静默)。一次性 typed 完整集干净。SullyOS 这种 caller 后续 1 行 diff 就能从 untyped spread 迁到 typed arg。 +SW 实际读取 7 个 notification 字段;只 typed 其中一部分会让剩余字段继续绕过 builder 校验,表现成“代码能过、行为静默不生效”。这版一次性补齐完整字段集,caller 可以直接从手写 spread 迁到 typed arg。 ### 行为兼容 diff --git a/packages/rei-standard-amsg/shared/README.md b/packages/rei-standard-amsg/shared/README.md index cc8397c..404b6a3 100644 --- a/packages/rei-standard-amsg/shared/README.md +++ b/packages/rei-standard-amsg/shared/README.md @@ -48,6 +48,30 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any --- +## Notification directive + +`ContentPush` and `ToolRequestPush` can carry an optional +`notification` object. It is a producer-side hint consumed by +`@rei-standard/amsg-sw` before rendering a system notification. + +| Field | Type | Notes | +|----------------------|-------------------------------------------|-------| +| `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy. `auto` follows SW defaults. | +| `title` | `string?` | Notification title override. | +| `body` | `string?` | Notification body override. | +| `icon` | `string?` | Notification icon URL. | +| `badge` | `string?` | Notification badge URL. | +| `tag` | `string?` | Notification grouping tag. | +| `renotify` | `boolean?` | Re-alert when a matching `tag` replaces an existing notification. | +| `requireInteraction` | `boolean?` | Keep the notification visible until the user dismisses it. | +| `silent` | `boolean?` | Suppress notification sound and vibration. | +| `data` | `Record?` | Custom data passed to the notification. | + +Unknown fields are preserved for forward compatibility, but the known +fields above are validated by the builders when present. + +--- + ## Per-kind fields ### `ContentPush` — final user-facing content diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js index 0cfafb7..9ebc308 100644 --- a/packages/rei-standard-amsg/shared/src/index.js +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -102,29 +102,33 @@ export const PUSH_SOURCE = Object.freeze({ /** * SW-rendering directive. Mirrors the fields that `amsg-sw`'s - * `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`) - * — typing all fields so callers don't lose IDE checking and slip back into the untyped-spread footgun. + * `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`) + * so producers get builder validation for the fields the SW actually reads. * - * Routing in SW (kept here so producers don't have to cross-check): + * Routing in SW: * - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads) * will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently. * - `show: "always"`, `"when-hidden"`, or `false` overrides this default. - * - When rendering, `notification.*` is consulted, with per-field fallback to - * the top-level `title` / `avatarUrl` / `messageId` and finally - * to the SW's `defaultIcon` / `defaultBadge` options. Everything - * else (`tag`, `renotify`, `requireInteraction`) has no top-level - * fallback — set them under `notification` or accept the SW default. + * - When rendering, `notification.*` is consulted first, with per-field + * fallback to the matching top-level payload fields (`title`, + * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`, + * `renotify`, `requireInteraction`, `silent`, `data`), and finally to + * the SW's `defaultIcon` / `defaultBadge` options (boolean knobs + * default to `false` at the SW). Prefer setting overrides under + * `notification` for explicitness; top-level fallback exists so that + * legacy un-namespaced payloads keep working byte-for-byte. * * @typedef {Object} NotificationDirective * @property {"auto" | "always" | "when-hidden" | false} [show] - Rendering strategy. Defaults to "auto" (render only if messageKind is content). - * @property {string} [title] - Notification title override. - * @property {string} [body] - Notification body override. - * @property {string} [icon] - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`). - * @property {string} [badge] - Badge URL override (falls back to SW `defaultBadge`). - * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification. - * @property {boolean} [renotify] - When tag matches, still vibrate/sound. Default false at SW. - * @property {boolean} [requireInteraction] - Notification stays until user dismisses. Default false at SW. - * @property {Record} [data] - Custom payload data to attach to the notification. + * @property {string} [title] - Notification title override (falls back to top-level `title`, then `来自 {contactName}`). + * @property {string} [body] - Notification body override (falls back to top-level `body`, then `message`). + * @property {string} [icon] - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`). + * @property {string} [badge] - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`). + * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag). + * @property {boolean} [renotify] - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW). + * @property {boolean} [requireInteraction] - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW). + * @property {boolean} [silent] - Suppress sound and vibration (falls back to top-level `silent`, default false at SW). + * @property {Record} [data] - Custom payload data to attach to the notification (falls back to top-level `data`). */ /** @@ -434,7 +438,7 @@ export function buildToolRequestPush(args) { * Validate the optional `notification` argument. * Plain object required (`null` / arrays / primitives rejected); field-level shape is * checked best-effort — `title` / `body` / `icon` / `badge` / `tag` - * must be strings when present, `renotify` / `requireInteraction` + * must be strings when present, `renotify` / `requireInteraction` / `silent` * must be booleans. Unknown keys are tolerated so the SW's * forward-compatibility (it just won't read them) is preserved. * @@ -455,7 +459,7 @@ function validateNotificationArg(kind, value) { throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`); } } - for (const f of ['renotify', 'requireInteraction']) { + for (const f of ['renotify', 'requireInteraction', 'silent']) { if (n[f] !== undefined && typeof n[f] !== 'boolean') { throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`); } @@ -568,10 +572,10 @@ const REASONING_CHUNK_DECODER = new TextDecoder('utf-8', { fatal: true }); /** * Slice a string into UTF-8 byte chunks no larger than `maxBytes`, * always cutting at codepoint boundaries (never inside a multi-byte - * char). Designed for the {@link ReasoningPush} byte-chunking path - * in amsg-instant — producers facing the ~3 KB Web Push payload - * limit slice oversized reasoning into N pushes with - * `chunkIndex` / `totalChunks`, the SW reassembles by concat. + * char). This is a generic byte-safe string helper retained for + * callers that need deterministic UTF-8 chunking around small Web Push + * payload budgets; current amsg-instant oversized payload delivery uses + * BlobStore / generic multipart instead of reasoning-only wire fields. * * Algorithm: TextEncoder → Uint8Array → backward scan from each * candidate cut index until the byte is a UTF-8 lead byte (any byte diff --git a/packages/rei-standard-amsg/shared/test/builders.test.mjs b/packages/rei-standard-amsg/shared/test/builders.test.mjs index 844c265..254330e 100644 --- a/packages/rei-standard-amsg/shared/test/builders.test.mjs +++ b/packages/rei-standard-amsg/shared/test/builders.test.mjs @@ -125,13 +125,7 @@ test('buildReasoningPush rejects empty reasoningContent', () => { ); }); -// ─── notification typed support (next.3+) ───────────────────────────── -// -// `notification` was previously an untyped spread footgun — hook -// authors could write any of the seven fields amsg-sw reads -// (`title` / `body` / `icon` / `badge` / `tag` / `renotify` / -// `requireInteraction`), but the builder didn't accept it as a typed -// arg so spread-based usage bypassed the IDE. +// ─── notification directive validation ───────────────────────────────── test('buildContentPush threads notification through verbatim', () => { const notification = { @@ -142,6 +136,7 @@ test('buildContentPush threads notification through verbatim', () => { tag: 'thread-42', renotify: true, requireInteraction: false, + silent: true, }; const push = buildContentPush({ ...COMMON, message: 'hi', notification }); assert.deepEqual(push.notification, notification); @@ -174,8 +169,8 @@ test('buildContentPush rejects non-string notification.{title,body,icon,badge,ta } }); -test('buildContentPush rejects non-boolean notification.{renotify,requireInteraction}', () => { - for (const field of ['renotify', 'requireInteraction']) { +test('buildContentPush rejects non-boolean notification.{renotify,requireInteraction,silent}', () => { + for (const field of ['renotify', 'requireInteraction', 'silent']) { assert.throws( () => buildContentPush({ ...COMMON, message: 'hi', notification: { [field]: 'yes' } }), new RegExp(`'notification\\.${field}' must be a boolean`), From 588251c8145fbadeba9e9ff06bf7633ac2fa9226 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:17:45 +0800 Subject: [PATCH 4/9] feat(amsg-sw): add delivery dedupe, page delivery bridge, and notification repair fixes - Add packagewise delivery dedupe (IndexedDB add() + keyPath atomic claim, TTL lazy cleanup). Web Push, multipart, blob envelope, and page bridge payloads all flow through the same gate before notification / postMessage / onBusinessPayload. - Add REI_AMSG_DELIVER message protocol so SSE page bridges and Web Push share the same SW pipeline. - Add notification repair: if the first delivery suppresses the notification (e.g. visible client) but a later backup satisfies notification.show, SW renders exactly one notification through onDuplicate. - Fix: notificationStatePending now clears as soon as the notification policy is settled. Previously a slow onBusinessPayload kept pending set, swallowing backup-driven repairs as 'first-delivery-pending'. - Fix: dedupe.storeName is no longer configurable (passing it throws). Changing storeName under the same dbName requires IDB version migration that this package does not provide; isolate via dbName instead. - Spec updated to reflect the new dedupe + bridge flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/sw/CHANGELOG.md | 27 + packages/rei-standard-amsg/sw/README.md | 90 +++- packages/rei-standard-amsg/sw/src/index.js | 504 ++++++++++++++++-- .../sw/test/dispatch.test.mjs | 444 ++++++++++++++- standards/service-worker-specification.md | 22 +- 5 files changed, 1045 insertions(+), 42 deletions(-) diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index 0eff2ee..fbe26d1 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog — @rei-standard/amsg-sw +## 2.2.0 — delivery dedupe + SSE bridge + +- **New**: `installReiSW({ dedupe })` 新增通用 delivery dedupe,默认开启。默认 key 为 `payload.messageId` → `payload.id` → `payload.dedupeKey`,没有 key 时保持兼容不去重。 +- **New**: dedupe gate 发生在 `showNotification` 和 `onBusinessPayload` 之前;重复 payload 不重复调用业务回调。若首包未展示系统通知、重复包到达时 `notification.show` 条件满足,SW 会只补一次通知,并通过 `onDuplicate(info)` 通知应用。 +- **New**: 新增页面到 SW 的通用业务投递协议 `{ type:'REI_AMSG_DELIVER', payload, source?, requestId? }`,用于让 SSE page bridge 和 Web Push 进入同一条 pipeline。 +- **New**: 文档明确生产推荐链路:`amsg-instant` always-on Web Push backup + client `REI_AMSG_DELIVER` bridge + SW 默认 dedupe。 +- **New**: dedupe 使用 IndexedDB keyPath + `add()` 做原子 claim,默认 DB 为 `rei_amsg_sw_dedupe_v1`,TTL 懒清理,无需 KV / D1 / Durable Object。 +- **Fix**: multipart 还原后的最终 payload、携带 `messageId` 的 blob envelope、Web Push payload、SSE bridge payload 都走同一套 dedupe gate。 +- **Changed**: `dedupe.storeName` 不再可配置,传了会在 `installReiSW` 安装时抛 Error。需要隔离去重数据改用 `dedupe.dbName` —— 每个 dbName 是独立 IndexedDB instance,互不影响。 + + 原因:同一 dbName 下换 storeName 需要做 IndexedDB 版本升级,本包不打算维护跨 storeName 的 migration 逻辑;继续暴露 storeName 配置只会让用户踩 IDB upgrade 坑(升级一次后 store 永远建不出来,所有 dedupe transaction 都抛 NotFoundError)。 + + | 之前配置 | 之前行为 | 现在 | + | --- | --- | --- | + | 不传 dbName / storeName | 用默认 | 不变 | + | 只传 `dbName` | 静默失效(store 建不出来) | 正常隔离 | + | 只传 `storeName` | 静默失效 | 装包时抛 Error | + | 同时传 `dbName` + `storeName`(首次部署) | OK | 装包时抛 Error | + | 同时传 `dbName` + 后续改 `storeName` | 老 client 上 store 建不出来,整条 dedupe 链路挂掉 | 装包时抛 Error | + +- **Fix**: 慢的 `onBusinessPayload` 回调不再阻塞 dedupe 的通知补救判定。 + + 之前:业务回调长时间未 resolve + 前台从可见变隐藏 + 同 `messageId` 的 Web Push backup 在窗口内到达 → backup 被判为 "first-delivery-pending" 丢弃,用户看不到通知。 + + 现在:通知决策一确定就解锁补救路径,backup 照常补出系统通知。业务回调依旧 await,`event.waitUntil` 生命周期不变。 +- **Changed**: `@rei-standard/amsg-shared` 精确依赖升级到 `0.2.0`,并支持 `notification.silent` 透传到 `showNotification()`。 + ## 2.1.1 — multipart 并发与 hook thenable 修复 - **Fix**: `_multipart` reassembly 现在按 multipart id 串行处理分片,避免并发 push delivery 下 IndexedDB read-modify-write 交错导致 `receivedCount` / `receivedBytes` 丢写,最终卡住重组。 diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 1e5b42d..869ea98 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -52,7 +52,10 @@ navigator.serviceWorker.addEventListener('message', (e) => { - `"when-hidden"`:仅当没有 `visibilityState === "visible"` 的客户端时才弹系统通知。如果应用在前台,则静默。 - `false`:强制不弹通知,即使是 `content`。适合完全交给应用自行接管或自绘弹窗的场景。 -当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。 +当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `renotify`, `requireInteraction`, `silent`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。 + +> **APNs / iOS Web Push 提醒** +> 如果业务大量发送后台 push 却长期不展示可见通知,iOS Web Push 的送达可能被系统策略影响。生产环境建议对后台消息使用 `notification.show = "always"` 或 `"when-hidden"`,再配合 `tag` 折叠与 `silent: true` 降低打扰。 #### 场景示例 @@ -88,9 +91,92 @@ navigator.serviceWorker.addEventListener('message', (e) => { > **注意:对于 multipart 传输** > 当 payload 通过 `_multipart` 分片时,未收齐前不仅不派发业务事件,也**绝不**弹系统通知。收齐并还原为原始 payload 后,再按原始 payload 的 `notification.show` 策略执行判定。 +### Delivery dedupe(通知前去重) + +`installReiSW()` 默认启用包级 dedupe。所有业务 payload 不管来自 Web Push、multipart 还原、blob envelope,还是页面通过 `postMessage` 桥接进 SW,都会先经过同一个 gate: + +``` +dedupe -> notification.show 策略 -> showNotification / postMessage / onBusinessPayload +``` + +第一次到达的 payload 会正常走 `notification.show` 策略、窗口广播和 `onBusinessPayload`。重复 payload **不会**再次广播,也**不会**再次调用 `onBusinessPayload`;如果第一次到达时因为前台可见等原因没有展示系统通知,而后到的 Web Push backup 已经满足 `notification.show` 条件,SW 会只补一次系统通知,然后把结果放进 `onDuplicate(info)`。这层去重发生在业务落地前面,不依赖业务层 inbox 自己兜底。 + +默认 key 按顺序读取: + +1. `payload.messageId` +2. `payload.id` +3. `payload.dedupeKey` + +没有 key 时不去重,保持旧 payload 兼容。multipart 会先还原成原始 payload 再取 key;blob envelope 如果携带 `messageId` / `id` / `dedupeKey`,也会被同一套 gate 覆盖。 + +```js +installReiSW(self, { + dedupe: { + enabled: true, // 默认 true + ttlMs: 10 * 60_000, // 默认 10 分钟 + dbName: 'rei_amsg_sw_dedupe_v1', // 想隔离另一套去重数据就改这个;每个 dbName 是独立 IDB instance + key: (payload) => payload.messageId, + }, + onDuplicate: async (info) => { + // { key, source, messageKind, firstSeenAt, existingSource, + // existingMessageKind, existingNotificationShown, duplicateNotificationShown } + }, +}); +``` + +实现使用 IndexedDB 的 `add()` + keyPath 做原子 claim:第一次 add 成功才放行;几乎同时到达的同 key payload,后到者会命中 `ConstraintError` 并作为 duplicate 返回。TTL 清理是懒清理,不需要 KV / D1 / Durable Object。 + +### 页面 -> SW 业务投递 + +SSE 默认先进页面主线程。若要让 SSE payload 和 Web Push backup 共用 SW 的 dedupe / notification / `onBusinessPayload` 管线,页面可以把 payload 转交给 SW: + +```js +const registration = await navigator.serviceWorker.ready; +const channel = new MessageChannel(); + +channel.port1.onmessage = (event) => { + // 成功:{ ok: true, duplicate?: boolean, key?: string, requestId?: string } + // 失败:{ ok: false, error: string, key?: string, requestId?: string } +}; + +registration.active?.postMessage({ + type: 'REI_AMSG_DELIVER', + source: 'sse', + requestId: crypto.randomUUID(), + payload, +}, [channel.port2]); +``` + +Web Push `push` event 和 `REI_AMSG_DELIVER` 最终都会进入同一个内部 pipeline。SSE 先到时,后来的 Web Push backup 会被 dedupe;Web Push 先到时,后来的 SSE bridge 也会被 dedupe。若首包已经落过业务但没弹通知,重复包只负责按当前 `notification.show` 策略补通知,不会重复触发业务回调。 + +### 生产推荐链路:SSE + Web Push backup + SW dedupe + +0.9.0 / 2.2.0 起,正式环境推荐把“双路投递、包层去重”当作默认责任边界。`amsg-instant` 固定 `backupPush:'on'`,所以 Worker 不需要等断线才发 backup;client 收到 SSE 后应立刻桥接给 SW;SW 负责统一去重、补通知和业务落地。 + +| 环节 | 包配置 / 调用 | 推荐值 | 责任 | +|------|---------------|--------|------| +| Worker 侧 SSE | `createInstantHandler({ sse })` | 可省略;等价于 `backupPush:'on'`, `keepaliveMs:1_000`, `immediateKeepalive:true` | SSE 正常流式返回,同时每条 payload 都发 Web Push backup | +| Client 侧 SSE → SW | `consumeInstantStream(..., { onPayload })` 内立刻 `postMessage({ type:'REI_AMSG_DELIVER', payload, source:'sse', requestId })` | 强烈推荐 | 让 SSE 与 Web Push 进入同一条 SW delivery / dedupe 管线 | +| SW 侧 dedupe | `installReiSW(self, { dedupe })` | 可省略;默认启用,key 为 `messageId` → `id` → `dedupeKey`,TTL 10 分钟 | 先到者触发业务,后到者不重复入库;必要时只补系统通知 | +| 通知策略 | `payload.notification.show` | 普通内容推荐 `'when-hidden'`;低打扰更新可加 `silent:true` + `tag` | 前台交给 UI,隐藏/关闭后由 Web Push backup 补通知 | + +一个最小形态: + +```js +installReiSW(self, { + defaultIcon: './icons/icon-192.png', + defaultBadge: './icons/icon-192.png', + multipart: { enabled: true }, + onBusinessPayload: async (payload) => persistIncomingPayload(payload), + onDuplicate: async (info) => traceDuplicate(info), +}); +``` + +这样当前台页面还活着时,SSE bridge 先进入 SW,`notification.show:'when-hidden'` 不弹系统通知但会触发业务落地;如果页面随后隐藏或已关闭,Web Push backup 到达 SW 后会命中同一个 key,只补通知,不重复调用 `onBusinessPayload`。 + ### Blob envelope -当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 +当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type?, messageId?, id?, dedupeKey? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 ### Generic multipart transport(2.1.0+) diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 82fc620..39c0000 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -60,6 +60,10 @@ const REI_SW_MULTIPART_DONE_STORE = 'multipart-done'; const REI_SW_MULTIPART_CHUNK_STORE = 'multipart-chunk'; const REI_SW_DB_VERSION = 3; let cachedDB = null; +const REI_AMSG_DEDUPE_DB_NAME = 'rei_amsg_sw_dedupe_v1'; +const REI_AMSG_DEDUPE_STORE = 'delivery-dedupe'; +const DEFAULT_DEDUPE_TTL_MS = 10 * 60_000; +const DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS = 60_000; const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox'; const MULTIPART_MESSAGE_KIND = '_multipart'; const MULTIPART_ENCODING = 'json-utf8-base64url'; @@ -74,6 +78,7 @@ const memoryMultipartPending = new Map(); const memoryMultipartDone = new Map(); const memoryMultipartChunks = new Map(); const multipartLocks = new Map(); +const dedupeDbCache = new Map(); /** * Wire-level message type for SW → client postMessage envelopes. @@ -103,10 +108,13 @@ export const REI_SW_EVENT = Object.freeze({ export const REI_SW_MESSAGE_TYPE = Object.freeze({ ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST', + DELIVER: 'REI_AMSG_DELIVER', FLUSH_QUEUE: 'REI_FLUSH_QUEUE', QUEUE_RESULT: 'REI_QUEUE_RESULT' }); +export const REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER; + /** * @typedef {Object} ReiSWOptions * @property {string} [defaultIcon] - Fallback notification icon URL. @@ -117,7 +125,14 @@ export const REI_SW_MESSAGE_TYPE = Object.freeze({ * @property {number} [multipart.maxTotalBytes=256000] * @property {number} [multipart.maxChunks=128] * @property {number} [multipart.cleanupIntervalMs=900000] + * @property {Object} [dedupe] + * @property {boolean} [dedupe.enabled=true] + * @property {number} [dedupe.ttlMs=600000] + * @property {number} [dedupe.cleanupIntervalMs=60000] + * @property {(payload: any) => string | undefined} [dedupe.key] + * @property {string} [dedupe.dbName='rei_amsg_sw_dedupe_v1'] - 隔离去重数据用。每个 dbName 对应一个独立的 IndexedDB instance,互不影响。`dedupe.storeName` 不再可配(传了会抛错);本包不维护跨 dbName 的迁移逻辑。 * @property {(payload: any) => void | Promise} [onBusinessPayload] + * @property {(info: { key: string, source: string, messageKind?: string, firstSeenAt?: number, existingSource?: string, existingMessageKind?: string, existingNotificationShown?: boolean, duplicateNotificationShown?: boolean }) => void | Promise} [onDuplicate] */ /** @@ -130,20 +145,28 @@ export function installReiSW(sw, opts = {}) { const defaultIcon = opts.defaultIcon || '/icon-192x192.png'; const defaultBadge = opts.defaultBadge || '/badge-72x72.png'; const multipart = normalizeMultipartOptions(opts.multipart); + const dedupe = normalizeDedupeOptions(opts.dedupe); let lastMultipartCleanupAt = 0; + let lastDedupeCleanupAt = 0; + const makeDeliveryContext = (source) => ({ + defaultBadge, + defaultIcon, + dedupe, + multipart, + onDuplicate: opts.onDuplicate, + onBusinessPayload: opts.onBusinessPayload, + source, + getLastDedupeCleanupAt: () => lastDedupeCleanupAt, + setLastDedupeCleanupAt: (value) => { lastDedupeCleanupAt = value; }, + getLastMultipartCleanupAt: () => lastMultipartCleanupAt, + setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; }, + }); sw.addEventListener('push', (event) => { const payload = readPushPayload(event); if (!payload) return; - event.waitUntil(handlePushPayload(sw, payload, { - defaultBadge, - defaultIcon, - multipart, - onBusinessPayload: opts.onBusinessPayload, - getLastMultipartCleanupAt: () => lastMultipartCleanupAt, - setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; }, - })); + event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext('webpush'))); }); sw.addEventListener('message', (event) => { @@ -157,6 +180,11 @@ export function installReiSW(sw, opts = {}) { return; } + if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) { + event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext())); + return; + } + if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) { event.waitUntil(flushQueuedRequests(sw)); } @@ -175,18 +203,58 @@ async function handlePushPayload(sw, payload, ctx) { if (!ctx.multipart.enabled) return; const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart); if (!restoredPayload) return; - await handlePushPayload(sw, restoredPayload, ctx); - return; + return handlePushPayload(sw, restoredPayload, ctx); + } + + const claim = await claimDedupe(payload, ctx); + if (claim.duplicate) { + const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx); + claim.duplicateNotification = duplicateNotification; + await notifyDuplicate(payload, claim, ctx); + return { ...claim, duplicateNotification }; } await dispatchBusinessPayload(sw, payload, { defaultIcon: ctx.defaultIcon, defaultBadge: ctx.defaultBadge, onBusinessPayload: ctx.onBusinessPayload, + }, async (intermediateResult) => { + // Settle the dedupe pending flag as soon as the notification policy + // is decided (dispatch + showNotification done) — do NOT wait for + // onBusinessPayload. A backup arriving mid-business would otherwise + // hit `notificationStatePending` and skip the repair path. + await updateDedupeNotificationState(claim, ctx, intermediateResult); }); + return claim; +} + +async function handleDeliverMessage(sw, event, message, ctx) { + let result = {}; + try { + if (!Object.prototype.hasOwnProperty.call(message, 'payload')) { + throw new Error('[rei-standard-amsg-sw] REI_AMSG_DELIVER requires payload'); + } + const source = typeof message.source === 'string' && message.source + ? message.source + : 'message'; + result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {}; + respondToSender(event, { + ok: true, + duplicate: Boolean(result.duplicate), + key: result.key, + requestId: message.requestId, + }); + } catch (error) { + respondToSender(event, { + ok: false, + error: error instanceof Error ? error.message : 'Failed to deliver payload', + key: result && result.key, + requestId: message.requestId, + }); + } } -async function dispatchBusinessPayload(sw, payload, defaults) { +async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) { const eventName = resolveEventName(payload); let clientList = []; @@ -198,47 +266,56 @@ async function dispatchBusinessPayload(sw, payload, defaults) { } catch (_matchError) { // Ignored } - - let shouldRenderNotification = false; - const showOpt = payload && payload.notification ? payload.notification.show : undefined; - if (showOpt === 'always') { - shouldRenderNotification = true; - } else if (showOpt === 'when-hidden') { - const hasVisibleClient = clientList.some(client => client.visibilityState === 'visible'); - shouldRenderNotification = !hasVisibleClient; - } else if (showOpt === false) { - shouldRenderNotification = false; - } else { - shouldRenderNotification = isNotificationKind(payload); - } + const notificationState = { + shouldRender: shouldRenderNotification(payload, clientList), + shown: false, + }; /** @type {Array>} */ - const work = [dispatchPushToClients(sw, eventName, payload, clientList)]; + const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)]; - if (shouldRenderNotification) { + if (notificationState.shouldRender) { const notification = createNotificationFromPayload(payload, defaults); if (notification) { - work.push( + notificationWork.push( sw.registration.showNotification(notification.title, notification.options) + .then(() => { + notificationState.shown = true; + }) ); } } + // Kick the user's business callback off in parallel with notification + // work, but do NOT block notification-state settlement on it. A slow + // onBusinessPayload would otherwise keep `notificationStatePending` + // set, and a Web Push backup arriving in that window would be swallowed + // as 'first-delivery-pending' with no chance to repair a missed + // notification. The overall waitUntil chain still awaits the business + // callback below so the SW does not get killed mid-flight. + let businessWork = null; if (typeof defaults.onBusinessPayload === 'function') { try { const result = defaults.onBusinessPayload(payload); if (result && typeof result.then === 'function') { - work.push(Promise.resolve(result).catch(error => { + businessWork = Promise.resolve(result).catch(error => { console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error); - })); + }); } } catch (error) { console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error); } } - await Promise.all(work); + await Promise.all(notificationWork); + const settledResult = { eventName, notification: notificationState }; + if (typeof onNotificationSettled === 'function') { + await onNotificationSettled(settledResult); + } + if (businessWork) await businessWork; + + return settledResult; } /** @@ -270,7 +347,6 @@ function resolveEventName(payload) { * `messageKind: 'content'` renders a notification; everything else * (`reasoning`, `tool_request`, `error`) is dispatched silently so * apps can render them in-app. - * * Legacy payloads with no `messageKind` field still render a * notification — that's the 2.0.x back-compat path. * @@ -284,6 +360,21 @@ function isNotificationKind(payload) { return kind === MESSAGE_KIND.CONTENT; } +function shouldRenderNotification(payload, clientList) { + const showOpt = payload && payload.notification ? payload.notification.show : undefined; + + if (showOpt === 'always') { + return true; + } + if (showOpt === 'when-hidden') { + return !clientList.some(client => client.visibilityState === 'visible'); + } + if (showOpt === false) { + return false; + } + return isNotificationKind(payload); +} + /** * Broadcast a parsed push payload to every controlled client. Failures * on individual `postMessage` calls are swallowed — one offline tab @@ -374,7 +465,8 @@ function createNotificationFromPayload(payload, defaults) { renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false), requireInteraction: Boolean( pushNotification.requireInteraction ?? payload.requireInteraction ?? false - ) + ), + silent: Boolean(pushNotification.silent ?? payload.silent ?? false) } }; } @@ -398,10 +490,240 @@ function normalizeMultipartOptions(input) { }; } +function normalizeDedupeOptions(input) { + const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; + + // storeName 不再可配。同 dbName 下 storeName 一变就要做 IDB 版本升级, + // 暴露这个配置点的收益(一个内部 store 名字)远小于让用户踩 IDB upgrade + // 坑的代价。隔离用 dbName —— 每个 dbName 是独立 IndexedDB instance。 + if (Object.prototype.hasOwnProperty.call(source, 'storeName')) { + throw new Error( + '[rei-standard-amsg-sw] dedupe.storeName 不再可配置。改 storeName 会触发 IndexedDB 版本升级,' + + '本包不维护 migration 逻辑。需要隔离去重数据请改用 dedupe.dbName(每个 dbName 是独立 IDB 实例)。' + ); + } + + return { + enabled: source.enabled !== false, + ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_DEDUPE_TTL_MS), + cleanupIntervalMs: source.cleanupIntervalMs === 0 + ? 0 + : positiveIntegerOrDefault( + source.cleanupIntervalMs, + DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS + ), + key: typeof source.key === 'function' ? source.key : null, + dbName: typeof source.dbName === 'string' && source.dbName.trim() + ? source.dbName.trim() + : REI_AMSG_DEDUPE_DB_NAME, + storeName: REI_AMSG_DEDUPE_STORE, + _memoryStore: new Map(), + }; +} + function positiveIntegerOrDefault(value, fallback) { return Number.isInteger(value) && value > 0 ? value : fallback; } +async function claimDedupe(payload, ctx) { + if (!ctx.dedupe || ctx.dedupe.enabled === false) { + return { duplicate: false, key: undefined }; + } + + const key = resolveDedupeKey(payload, ctx.dedupe); + if (!key) return { duplicate: false, key: undefined }; + + await maybeCleanupDedupe(ctx); + + const now = Date.now(); + const record = { + key, + firstSeenAt: now, + expiresAt: now + ctx.dedupe.ttlMs, + source: ctx.source || 'unknown', + messageKind: getPayloadMessageKind(payload), + notificationShown: false, + notificationStatePending: true, + }; + + if (await addDedupeRecord(ctx.dedupe, record)) { + return { duplicate: false, key, record }; + } + + const existing = await readDedupeRecord(ctx.dedupe, key); + if (existing && existing.expiresAt <= now) { + await deleteDedupeRecord(ctx.dedupe, key); + if (await addDedupeRecord(ctx.dedupe, record)) { + return { duplicate: false, key, record }; + } + } + + return { + duplicate: true, + key, + record, + existing: existing || null, + }; +} + +async function updateDedupeNotificationState(claim, ctx, dispatchResult) { + if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return; + if (!dispatchResult || !dispatchResult.notification) return; + + const notification = dispatchResult.notification; + const next = { + ...claim.record, + notificationShown: notification.shown === true, + notificationStatePending: false, + }; + + try { + await putDedupeRecord(ctx.dedupe, next); + claim.record = next; + } catch (error) { + console.error('[rei-standard-amsg-sw] dedupe notification state update failed:', error); + } +} + +async function maybeShowDuplicateNotification(sw, payload, claim, ctx) { + const existing = claim && claim.existing ? claim.existing : null; + if (!existing || existing.notificationShown === true) { + return { shown: false, reason: existing ? 'already-shown' : 'no-existing-record' }; + } + if (existing.notificationStatePending === true) { + return { shown: false, reason: 'first-delivery-pending' }; + } + + let clientList = []; + try { + clientList = await sw.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + } catch (_matchError) { + // Ignored + } + + if (!shouldRenderNotification(payload, clientList)) { + return { shown: false, reason: 'policy-suppressed' }; + } + + const notification = createNotificationFromPayload(payload, { + defaultIcon: ctx.defaultIcon, + defaultBadge: ctx.defaultBadge, + }); + if (!notification) { + return { shown: false, reason: 'no-notification' }; + } + + await sw.registration.showNotification(notification.title, notification.options); + + const next = { + ...existing, + notificationShown: true, + notificationStatePending: false, + }; + await putDedupeRecord(ctx.dedupe, next); + + return { shown: true, reason: 'shown-from-duplicate' }; +} + +function resolveDedupeKey(payload, dedupe) { + if (typeof dedupe.key === 'function') { + try { + const custom = dedupe.key(payload); + return typeof custom === 'string' && custom.trim() ? custom.trim() : undefined; + } catch (error) { + console.error('[rei-standard-amsg-sw] dedupe.key error:', error); + return undefined; + } + } + + if (!payload || typeof payload !== 'object') return undefined; + for (const field of ['messageId', 'id', 'dedupeKey']) { + const value = payload[field]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +function getPayloadMessageKind(payload) { + return payload && typeof payload === 'object' && typeof payload.messageKind === 'string' + ? payload.messageKind + : undefined; +} + +async function notifyDuplicate(payload, claim, ctx) { + if (typeof ctx.onDuplicate !== 'function') return; + const existing = claim.existing || {}; + const info = { + key: claim.key, + source: ctx.source || 'unknown', + messageKind: getPayloadMessageKind(payload), + firstSeenAt: existing.firstSeenAt, + existingSource: existing.source, + existingMessageKind: existing.messageKind, + existingNotificationShown: existing.notificationShown === true, + duplicateNotificationShown: claim.duplicateNotification && claim.duplicateNotification.shown === true, + }; + try { + await ctx.onDuplicate(info); + } catch (error) { + console.error('[rei-standard-amsg-sw] onDuplicate error:', error); + } +} + +async function maybeCleanupDedupe(ctx) { + if (!ctx.dedupe || ctx.dedupe.enabled === false || ctx.dedupe.cleanupIntervalMs === 0) return; + const now = Date.now(); + const last = ctx.getLastDedupeCleanupAt ? ctx.getLastDedupeCleanupAt() : 0; + if (last && now - last < ctx.dedupe.cleanupIntervalMs) return; + if (ctx.setLastDedupeCleanupAt) ctx.setLastDedupeCleanupAt(now); + try { + await cleanupDedupeStore(ctx.dedupe, now); + } catch (error) { + console.error('[rei-standard-amsg-sw] dedupe cleanup failed:', error); + } +} + +async function cleanupDedupeStore(dedupe, now) { + if (!hasIndexedDB()) { + const store = memoryDedupeStoreFor(dedupe); + for (const [key, record] of store.entries()) { + if (record.expiresAt <= now) store.delete(key); + } + return; + } + + await withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => { + const index = store.index('expiresAt'); + const range = IDBKeyRange.upperBound(now); + let failed = false; + const request = index.openCursor(range); + request.onsuccess = () => { + if (failed) return; + const cursor = request.result; + if (!cursor) { + resolve(undefined); + return; + } + + const deleteRequest = cursor.delete(); + deleteRequest.onsuccess = () => { + if (failed) return; + cursor.continue(); + }; + deleteRequest.onerror = () => { + if (!failed) { + failed = true; + reject(deleteRequest.error || new Error('Failed to delete expired dedupe record')); + } + }; + }; + request.onerror = () => reject(request.error || new Error('Failed to scan expired dedupe records')); + }); +} + function isMultipartPush(payload) { return !!payload && typeof payload === 'object' && @@ -801,6 +1123,78 @@ function respondToSender(event, message) { } } +async function addDedupeRecord(dedupe, record) { + if (!hasIndexedDB()) { + const store = memoryDedupeStoreFor(dedupe); + if (store.has(record.key)) return false; + store.set(record.key, cloneRecord(record)); + return true; + } + + return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => { + let settled = false; + const request = store.add(record); + request.onsuccess = () => { + settled = true; + resolve(true); + }; + request.onerror = (event) => { + settled = true; + if (request.error && request.error.name === 'ConstraintError') { + if (event && typeof event.preventDefault === 'function') event.preventDefault(); + resolve(false); + return; + } + reject(request.error || new Error('Failed to add dedupe record')); + }; + store.transaction.onerror = () => { + if (!settled) reject(store.transaction.error || new Error('Dedupe transaction failed')); + }; + }); +} + +function readDedupeRecord(dedupe, key) { + if (!hasIndexedDB()) { + return Promise.resolve(cloneRecord(memoryDedupeStoreFor(dedupe).get(key) || null)); + } + + return withDedupeStore(dedupe, 'readonly', (store, resolve, reject) => { + const request = store.get(key); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error || new Error('Failed to read dedupe record')); + }); +} + +function putDedupeRecord(dedupe, record) { + if (!record || typeof record.key !== 'string' || !record.key) { + return Promise.resolve(); + } + + if (!hasIndexedDB()) { + memoryDedupeStoreFor(dedupe).set(record.key, cloneRecord(record)); + return Promise.resolve(); + } + + return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => { + const request = store.put(record); + request.onsuccess = () => resolve(undefined); + request.onerror = () => reject(request.error || new Error('Failed to put dedupe record')); + }); +} + +function deleteDedupeRecord(dedupe, key) { + if (!hasIndexedDB()) { + memoryDedupeStoreFor(dedupe).delete(key); + return Promise.resolve(); + } + + return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => { + const request = store.delete(key); + request.onsuccess = () => resolve(undefined); + request.onerror = () => reject(request.error || new Error('Failed to delete dedupe record')); + }); +} + function readMultipartPending(id) { return readStoreRecord(REI_SW_MULTIPART_STORE, id); } @@ -946,12 +1340,27 @@ async function withDatabaseStore(storeName, mode, handler) { }); } +async function withDedupeStore(dedupe, mode, handler) { + const db = await openDedupeDatabase(dedupe); + return new Promise((resolve, reject) => { + const transaction = db.transaction(dedupe.storeName, mode); + const store = transaction.objectStore(dedupe.storeName); + transaction.onerror = () => reject(transaction.error || new Error('Dedupe transaction failed')); + Promise.resolve(handler(store, resolve, reject)).catch(reject); + }); +} + function hasIndexedDB() { return typeof indexedDB !== 'undefined' && indexedDB && typeof indexedDB.open === 'function'; } +function memoryDedupeStoreFor(dedupe) { + if (!dedupe._memoryStore) dedupe._memoryStore = new Map(); + return dedupe._memoryStore; +} + function memoryStoreFor(storeName) { if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone; if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending; @@ -964,6 +1373,37 @@ function cloneRecord(record) { return JSON.parse(JSON.stringify(record)); } +function openDedupeDatabase(dedupe) { + const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`; + const cached = dedupeDbCache.get(cacheKey); + if (cached) return Promise.resolve(cached); + + return new Promise((resolve, reject) => { + const request = indexedDB.open(dedupe.dbName, 1); + + request.onupgradeneeded = () => { + const db = request.result; + const store = db.objectStoreNames.contains(dedupe.storeName) + ? request.transaction.objectStore(dedupe.storeName) + : db.createObjectStore(dedupe.storeName, { keyPath: 'key' }); + if (store && !store.indexNames.contains('expiresAt')) { + store.createIndex('expiresAt', 'expiresAt', { unique: false }); + } + }; + + request.onsuccess = () => { + const db = request.result; + dedupeDbCache.set(cacheKey, db); + db.onversionchange = () => { + db.close(); + dedupeDbCache.delete(cacheKey); + }; + resolve(db); + }; + request.onerror = () => reject(request.error || new Error('Failed to open dedupe database')); + }); +} + function openQueueDatabase() { if (cachedDB) return Promise.resolve(cachedDB); diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs index b9d13bc..36454a9 100644 --- a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import { installReiSW, REI_SW_EVENT, - REI_AMSG_POSTMESSAGE_TYPE + REI_AMSG_POSTMESSAGE_TYPE, + REI_AMSG_DELIVER_MESSAGE_TYPE } from '../src/index.js'; /** @@ -33,6 +34,12 @@ function createSwMock({ clientCount = 1, visibleCount = 0 } = {}) { } })); + function setVisibleCount(nextVisibleCount) { + clients.forEach((client, index) => { + client.visibilityState = index < nextVisibleCount ? 'visible' : 'hidden'; + }); + } + const sw = { addEventListener(name, handler) { listeners.set(name, handler); @@ -71,7 +78,31 @@ function createSwMock({ clientCount = 1, visibleCount = 0 } = {}) { await Promise.all(pending); } - return { sw, listeners, notifications, postedMessages, triggerPush }; + async function triggerMessage(message) { + const messageHandler = listeners.get('message'); + if (!messageHandler) throw new Error('message handler was never registered'); + + /** @type {Array>} */ + const pending = []; + /** @type {Array} */ + const replies = []; + const fakeEvent = { + data: message, + ports: [{ + postMessage(reply) { + replies.push(reply); + } + }], + waitUntil(work) { + pending.push(Promise.resolve(work)); + } + }; + messageHandler(fakeEvent); + await Promise.all(pending); + return replies; + } + + return { sw, listeners, notifications, postedMessages, triggerPush, triggerMessage, setVisibleCount }; } const COMMON = Object.freeze({ @@ -110,6 +141,385 @@ function buildMultipartPayloads(payload, { }); } +test('dedupe: WebPush duplicate messageId only dispatches once', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_webpush', + messageKind: 'content', + message: 'dedupe me', + }; + await triggerPush(payload); + await triggerPush(payload); + + assert.equal(notifications.length, 1); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].key, 'msg_dedupe_webpush'); + assert.equal(duplicates[0].source, 'webpush'); + assert.equal(duplicates[0].messageKind, 'content'); +}); + +test('dedupe: SSE bridge first, WebPush backup second is swallowed before notification', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_sse_first', + messageKind: 'content', + message: 'sse first', + }; + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-sse-first', + payload, + }); + await triggerPush(payload); + + assert.deepEqual(replies[0], { + ok: true, + duplicate: false, + key: 'msg_dedupe_sse_first', + requestId: 'req-sse-first', + }); + assert.equal(notifications.length, 1); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].source, 'webpush'); +}); + +test('dedupe: WebPush first, SSE bridge second is swallowed', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_webpush_first', + messageKind: 'content', + message: 'webpush first', + }; + await triggerPush(payload); + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-webpush-first', + payload, + }); + + assert.equal(notifications.length, 1); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].source, 'sse'); + assert.deepEqual(replies[0], { + ok: true, + duplicate: true, + key: 'msg_dedupe_webpush_first', + requestId: 'req-webpush-first', + }); +}); + +test('dedupe: SSE visible first, WebPush backup hidden later only repairs notification', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush, triggerMessage, setVisibleCount } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_notification_repair', + messageKind: 'content', + message: 'repair notification', + notification: { show: 'when-hidden', title: 'Hidden repair' }, + }; + + await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-repair', + payload, + }); + assert.equal(notifications.length, 0, 'visible SSE delivery should not show a notification'); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + + setVisibleCount(0); + await triggerPush(payload); + + assert.equal(notifications.length, 1, 'duplicate backup should repair notification when now hidden'); + assert.equal(notifications[0].title, 'Hidden repair'); + assert.equal(postedMessages.length, 1, 'duplicate backup must not re-post to clients'); + assert.equal(businessPayloads.length, 1, 'duplicate backup must not rerun business payload handling'); + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].existingNotificationShown, false); + assert.equal(duplicates[0].duplicateNotificationShown, true); +}); + +test('dedupe: backup push repairs notification while first delivery business callback is still pending', async () => { + // Regression: notificationStatePending must be cleared as soon as the + // notification policy is settled (dispatch + showNotification done), + // NOT when onBusinessPayload finally resolves. Otherwise a slow user + // callback keeps the pending flag set, and a Web Push backup arriving + // in that window gets swallowed by 'first-delivery-pending' with no + // second chance to repair the missing notification. + const businessStarted = []; + let releaseBusiness; + const businessGate = new Promise((resolve) => { releaseBusiness = resolve; }); + + const { sw, listeners, notifications, postedMessages, triggerPush, setVisibleCount } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + onBusinessPayload: (payload) => { + businessStarted.push(payload); + return businessGate; + }, + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_pending_repair', + messageKind: 'content', + message: 'pending repair', + notification: { show: 'when-hidden', title: 'Repair while pending' }, + }; + + // Kick SSE delivery without awaiting — business callback is gated, so + // the underlying handlePushPayload promise will not settle until we + // release it below. + const messageHandler = listeners.get('message'); + const ssePending = []; + messageHandler({ + data: { + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-pending-repair', + payload, + }, + ports: [{ postMessage() {} }], + waitUntil(work) { ssePending.push(Promise.resolve(work)); }, + }); + + // Let claim + notification work flush microtasks; business callback + // stays pending. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(businessStarted.length, 1, 'first delivery should have entered business callback'); + assert.equal(notifications.length, 0, 'visible client suppresses first-delivery notification'); + assert.equal(postedMessages.length, 1, 'first delivery still broadcasts to the client'); + + // Visibility flips to hidden; backup arrives while first delivery is + // still stuck inside its business callback. + setVisibleCount(0); + await triggerPush(payload); + + assert.equal( + notifications.length, + 1, + 'backup push should repair the notification even though first-delivery business callback is still pending', + ); + assert.equal(notifications[0].title, 'Repair while pending'); + + // Release the gate and let the original delivery wind down. + releaseBusiness(); + await Promise.all(ssePending); + assert.equal(postedMessages.length, 1, 'duplicate backup must not re-post to clients'); + assert.equal(businessStarted.length, 1, 'duplicate backup must not rerun business callback'); +}); + +test('dedupe: SSE visible first, WebPush backup still visible stays notification-silent', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush, triggerMessage } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_notification_still_visible', + messageKind: 'content', + message: 'still visible', + notification: { show: 'when-hidden', title: 'Should stay hidden' }, + }; + + await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-still-visible', + payload, + }); + await triggerPush(payload); + + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + assert.equal(duplicates.length, 1); + assert.equal(duplicates[0].existingNotificationShown, false); + assert.equal(duplicates[0].duplicateNotificationShown, false); +}); + +test('dedupe: payload without messageId/id/dedupeKey keeps legacy non-dedupe behavior', async () => { + const businessPayloads = []; + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + }); + + const payload = { + messageKind: 'content', + message: 'legacy no id', + title: 'No Key', + }; + await triggerPush(payload); + await triggerPush(payload); + + assert.equal(notifications.length, 2); + assert.equal(postedMessages.length, 2); + assert.equal(businessPayloads.length, 2); +}); + +test('dedupe: concurrent duplicate payloads only allow one winner', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_concurrent', + messageKind: 'content', + message: 'race', + }; + await Promise.all([triggerPush(payload), triggerPush(payload)]); + + assert.equal(notifications.length, 1); + assert.equal(postedMessages.length, 1); + assert.equal(businessPayloads.length, 1); + assert.equal(duplicates.length, 1); +}); + +test('dedupe: TTL expiry allows the same key again', async () => { + const businessPayloads = []; + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { + ttlMs: 5, + cleanupIntervalMs: 0, + dbName: 'rei_amsg_sw_dedupe_ttl_test', + }, + onBusinessPayload: (payload) => businessPayloads.push(payload), + }); + + const payload = { + ...COMMON, + messageId: 'msg_dedupe_ttl', + messageKind: 'content', + message: 'ttl', + }; + await triggerPush(payload); + await new Promise((resolve) => setTimeout(resolve, 10)); + await triggerPush(payload); + + assert.equal(notifications.length, 2); + assert.equal(businessPayloads.length, 2); +}); + +test('dedupe: multipart restore and blob envelopes use the same messageId gate', async () => { + const businessPayloads = []; + const duplicates = []; + const { sw, notifications, postedMessages, triggerPush, triggerMessage } = createSwMock(); + installReiSW(sw, { + multipart: { cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => businessPayloads.push(payload), + onDuplicate: (info) => duplicates.push(info), + }); + + const multipartPayload = { + ...COMMON, + messageId: 'msg_dedupe_multipart', + messageKind: 'content', + message: 'multipart body '.repeat(20), + title: 'Multipart Dedupe', + }; + await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + payload: multipartPayload, + }); + for (const part of buildMultipartPayloads(multipartPayload, { id: 'mp_dedupe_same_key', maxChunkBytes: 80 })) { + await triggerPush(part); + } + + const blobEnvelope = { + _blob: true, + key: 'blob-key', + url: 'https://worker.example.com/blob/blob-key', + messageKind: 'content', + messageId: 'msg_dedupe_blob', + }; + await triggerPush(blobEnvelope); + await triggerPush(blobEnvelope); + + assert.equal(notifications.length, 2, 'one multipart original + one blob envelope'); + assert.equal(postedMessages.length, 2); + assert.equal(businessPayloads.length, 2); + assert.equal(duplicates.length, 2); + assert.deepEqual(duplicates.map((info) => info.key).sort(), [ + 'msg_dedupe_blob', + 'msg_dedupe_multipart', + ]); +}); + +test('dedupe: passing storeName throws — config no longer supported, must isolate via dbName', () => { + const { sw } = createSwMock(); + assert.throws( + () => installReiSW(sw, { dedupe: { storeName: 'whatever' } }), + /dedupe\.storeName 不再可配置/, + ); +}); + +test('dedupe: custom dbName alone installs without error', () => { + const { sw } = createSwMock(); + assert.doesNotThrow(() => installReiSW(sw, { + dedupe: { dbName: 'isolated-db' }, + })); +}); + test('installReiSW registers the push listener', () => { const { sw, listeners } = createSwMock(); installReiSW(sw); @@ -377,11 +787,11 @@ test('generic multipart custom messageKind restores and dispatches UNKNOWN_RECEI const payload = { ...COMMON, - messageKind: 'emotion_update', - mood: 'curious', + messageKind: 'status_update', + status: 'ready', detail: 'x'.repeat(400) }; - const parts = buildMultipartPayloads(payload, { id: 'mp_sw_emotion', maxChunkBytes: 90 }); + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_status', maxChunkBytes: 90 }); for (const part of parts) { await triggerPush(part); @@ -428,12 +838,17 @@ test('generic multipart missing chunks do not dispatch and expire observably', a messageKind: 'reasoning', reasoningContent: 'partial '.repeat(50) }; - const parts = buildMultipartPayloads(payload, { id: 'mp_sw_expire', maxChunkBytes: 80, ttlMs: 1 }); + const ttlMs = 50; + const parts = buildMultipartPayloads(payload, { + id: 'mp_sw_expire', + maxChunkBytes: 80, + ttlMs, + }); await triggerPush(parts[0]); assert.equal(postedMessages.length, 0); - await new Promise((resolve) => setTimeout(resolve, 5)); + await new Promise((resolve) => setTimeout(resolve, ttlMs + 10)); await triggerPush({ ...COMMON, messageKind: 'error', code: 'NOOP', message: 'tick cleanup' }); const expired = postedMessages.find((entry) => @@ -588,6 +1003,21 @@ test('notification.data is passed through to notification options', async () => assert.equal(notifications[0].options.data.customField, 'value'); }); +test('notification.silent is passed through to notification options', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'content', + message: 'Quiet hello', + notification: { show: 'always', silent: true } + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].options.silent, true); +}); + test('multipart fully received payload with notification.show: "when-hidden" checks visible client', async () => { // Test 1: with visible client -> no notification { diff --git a/standards/service-worker-specification.md b/standards/service-worker-specification.md index 21f1cb8..3804f96 100644 --- a/standards/service-worker-specification.md +++ b/standards/service-worker-specification.md @@ -1375,9 +1375,26 @@ self.addEventListener('notificationclick', (event) => { ## 15. 变更日志 +### v2.2.0 (2026-05-31) + +#### 改进优化 + +**1. Delivery dedupe** +- `@rei-standard/amsg-sw` 在通知展示和 `onBusinessPayload` 前加入 delivery dedupe gate。 +- 默认 key 顺序为 `payload.messageId` → `payload.id` → `payload.dedupeKey`;没有 key 的 payload 不参与 dedupe。 +- 重复 payload 不重复调用业务回调;如果首包未展示系统通知、重复包到达时 `notification.show` 条件满足,会只补一次通知,并通过 `onDuplicate(info)` 暴露观测信息。 + +**2. 页面到 SW 的统一投递协议** +- 新增 `{ type: 'REI_AMSG_DELIVER', payload, source?, requestId? }`,让 SSE page bridge 和 Web Push 进入同一条 SW pipeline。 +- SSE 与 Web Push backup 共用同一个业务 key 时,先到者放行业务回调,后到者被 dedupe 收敛;若需要补系统通知,只补通知不重复业务。 +- 正式环境推荐搭配 `@rei-standard/amsg-instant` 固定 `sse.backupPush = "on"`:SSE 正常流式返回,同时每条 payload 也走 Web Push backup,最终由 SW dedupe 统一收敛。 + +**3. 无声通知** +- `notification.silent` 进入 shared 类型与 SW 渲染链路,可配合 `tag` 做折叠、低打扰通知。 + ### v2.1.0 (2026-05-25) -#### 🔧 改进优化 +#### 改进优化 **1. 三轴 Push Schema 与 `messageKind`** - 引入了 `messageKind` 属性,区分 `content`、`reasoning`、`tool_request`、`error` 类型的推送。 @@ -1387,6 +1404,9 @@ self.addEventListener('notificationclick', (event) => { - 支持通过 `notification.show` 显式控制系统通知行为(`auto`, `always`, `when-hidden`, `false`)。 - 进一步提升前台应用接管消息的自由度,免除不必要的通知打扰。 +> **APNs / iOS Web Push 提醒** +> 如果业务大量发送后台 push 却长期不展示可见通知,iOS Web Push 的送达可能被系统策略影响。生产环境建议对后台消息使用 `notification.show = "always"` 或 `"when-hidden"`,再配合 `tag` 折叠与 `silent: true` 降低打扰。 + **3. `onBusinessPayload` 与 Generic Multipart(SDK 功能)** - 统一了分片数据还原逻辑,移除了老的 `chunkIndex` 专属逻辑,改为基于 `_multipart` 进行可靠重组。 - 提供了 `onBusinessPayload` 钩子能力,安全拦截落地完整业务负载。 From 470e5d54906dfc9f1d40c51f940d180b1c86974c Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:17:56 +0800 Subject: [PATCH 5/9] feat(amsg-instant): always-on SSE backup push + configurable keepalive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE is the default transport. Successful SSE enqueue ALSO schedules a Web Push backup with the same messageId (always-on); amsg-sw / client dedupe collapses the two transports back to one delivery. - Reject sse.backupPush:'off'|'delayed' and sse.backupDelayMs configs at normalization — those modes had known payload-loss windows in production. - Add SSE keepalive controls: immediateKeepalive (default true) and keepaliveMs (default 1000, clamped to >= 250). - Add SSE transport onEvent diagnostics: sse_payload_enqueued / _failed / _aborted / _canceled, backup_push_scheduled / _sent / _failed, fallback_push_sent / _failed. - Fix: ReadableStream.cancel(reason) marks the stream unusable so subsequent payloads fall back to Web Push. - Fix: blob envelopes carry the original payload's messageId / id / dedupeKey so SSE + blob backup share the same dedupe key in amsg-sw. - Drop delayMs field from backup_push_* events (always 0 after the always-on rewrite). - Centralise waitForPushCalls helper; handler tests reuse the shared one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 14 +- packages/rei-standard-amsg/instant/README.md | 60 +++++- .../instant/src/blob-store/interface.js | 10 +- .../rei-standard-amsg/instant/src/index.js | 161 +++++++++++--- .../instant/src/message-processor.js | 12 +- .../instant/test/agentic-loop.test.mjs | 12 +- .../instant/test/e2e.test.mjs | 18 +- .../instant/test/handler.test.mjs | 203 +++++++++++++++++- .../instant/test/helpers.mjs | 10 + 9 files changed, 423 insertions(+), 77 deletions(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 6452f7a..564e1ed 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog — @rei-standard/amsg-instant +## 0.9.0 — always-on SSE backup push + keepalive controls + +- **New**: SSE backup push 固定开启。SSE payload enqueue 成功后立即发送同 `messageId` 的 Web Push backup,配合 `@rei-standard/amsg-sw` 默认 dedupe 作为正式环境推荐链路。 +- **Changed**: `sse.backupPush:'off' | 'delayed'` 与 `sse.backupDelayMs` 在稳定版中移除并拒绝配置,避免正式部署误入已知可能丢 payload 的模式。 +- **New**: SSE keepalive 可配置,默认 `immediateKeepalive:true` + `keepaliveMs:1000`,并把 `keepaliveMs` clamp 到最小 250ms。 +- **New**: SSE transport 事件补齐:`sse_payload_enqueued`、`sse_payload_enqueue_failed`、`sse_stream_aborted`、`sse_stream_canceled`、`backup_push_scheduled`、`backup_push_sent`、`backup_push_failed`、`fallback_push_sent`、`fallback_push_failed`。`backup_push_*` 事件 payload 不再带 `delayMs` 字段(always-on backup 后永远是 0,留着只是噪声)。 +- **Fix**: SSE `ReadableStream.cancel(reason)` 现在会标记 stream 不可用,后续 payload 走 Web Push fallback。 +- **Fix**: Blob envelope 现在携带原始 payload 的 `messageId` / `id` / `dedupeKey`,让 SSE payload 与 blob backup 能在 `@rei-standard/amsg-sw` 层共用 dedupe。 +- **Docs**: `safeEnqueue` 内联注释跟 always-on backup 行为对齐——之前注释只描述了 fallback 路径,没提成功 enqueue 也会照样发同 `messageId` 的 backup push。注释修正,行为不变。 + ## 0.9.0-next.1 — SSE 分支接入 waitUntil (pre-release) 修补 `0.9.0-next.0` SSE 分支没接生命周期保护的遗漏。 @@ -50,7 +60,7 @@ onBeforeLoop?: (ctx: { requestBody, sessionId, metadata }) => unknown | Promise< onAfterLoop?: (ctx: { deliver, sessionId, metadata, requestBody, pending }) => Promise; ``` -`onBeforeLoop` 在主 LLM loop 启动**前**调用,约定 hook 同步启动副任务并返回 handle 对象(例如 `{ emotionEval: runEmotionEval(...) }`,里面的 promise 已经在跑)。框架只 await 函数返回——**不**会替你 await 副任务本身。返回值作为 `pending` 透传给 `onAfterLoop`,由后者按自己的结构 `await` 并通过 `deliver(payload)` 追加 push。 +`onBeforeLoop` 在主 LLM loop 启动**前**调用,约定 hook 同步启动副任务并返回 handle 对象(例如 `{ lookup: runBackgroundLookup(...) }`,里面的 promise 已经在跑)。框架只 await 函数返回——**不**会替你 await 副任务本身。返回值作为 `pending` 透传给 `onAfterLoop`,由后者按自己的结构 `await` 并通过 `deliver(payload)` 追加 push。 两个 hook 在 SSE 与纯 Push 两种传输模式下都生效,`deliver` 抹平差异——hook 作者不用关心当前哪种传输。`requestBody` 透传给 hook(框架不解析调用方的自定义字段)。 @@ -100,7 +110,7 @@ SSE 空闲时每 15 秒发一行 `: keepalive\n\n` 注释,防 CDN / 反向代 ## 0.8.0-next.6 — BREAKING: generic multipart transport (pre-release) -next 阶段把 oversized push 的 transport 收敛成一套通用 multipart 协议。旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除;`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,都可以被 `_multipart` 包装。 +next 阶段把 oversized push 的 transport 收敛成一套通用 multipart 协议。旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除;`reasoning`、`tool_request`、`content`、`error`、`status_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,都可以被 `_multipart` 包装。 ### New diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 42260e9..3d87d62 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -41,6 +41,7 @@ npm install @rei-standard/amsg-instant | `onAfterLoop` | function | ❌ | **0.9.0+**:主 loop 结束后、流关闭前调用,从 `pending` 拿到 `onBeforeLoop` 返回的副任务 handle,await 完用 `deliver(payload)` 追加 push | | `blobStore` | object | ❌ | **0.7.0+**:可选 blob 后端。push payload UTF-8 字节超过 `maxInlineBytes`(默认 2600)时自动把 body 写进 store、改推 200 B envelope。见 [BlobStore](#blobstore070) | | `multipart` | object | ❌ | **0.8.0+**:通用 multipart transport。超出 inline、且没配 BlobStore 时,任意 JSON-safe payload 都可拆成 `_multipart` 分片。默认 `enabled:true`、`maxChunkBytes:1800`、`ttlMs:60000`、`maxChunks:128`、`maxTotalBytes:256000`。见 [Generic multipart transport](#generic-multipart-transport080)。 | +| `sse` | object | ❌ | **0.9.0+**:SSE 传输配置。`backupPush` 固定为 `'on'`,每条 SSE payload enqueue 成功后也发送同 `messageId` 的 Web Push backup;`keepaliveMs` 默认 1000、最小 250;`immediateKeepalive` 默认 true。 | | `maxLoopIterations` | number | ❌ | **0.7.0+**:单次 worker 调用内 `decision:'continue'` 的硬上限,默认 10。仅防本进程内 hook 反复 continue 失控;跨请求的 `/continue` 洪水攻击由上游 auth/rate-limit 处理 | | `autoEmitReasoning` | boolean | ❌ | **0.8.0+**:默认 `true`。`true` 时框架在调 hook 前自动 emit `ReasoningPush`(如果 LLM 响应带非空 `reasoning_content`,或 `content` 内含 `` 等标签)。`false` 把 reasoning emit 完全交给 hook 自己负责(hook 可读 `ctx.llmResponse.choices[0].message.reasoning_content` 并用 `buildReasoningPush` + 自己 dispatch)。legacy 路径忽略此项始终自动 emit。 | | `reasoningChunkBytes` | number \| null | ❌ | **Deprecated in 0.8.0**:旧 reasoning 专用字节切配置。保留为 `multipart.maxChunkBytes` 的兼容别名;`null` 仅在未显式配置 `multipart` 时禁用 generic multipart。不会再产生 `chunkIndex` / `totalChunks` reasoning wire fields。 | @@ -104,7 +105,7 @@ const handler = createInstantHandler({ | 请求头 | 响应 | 适用场景 | |---------------------------------|-----------------------------------|-----------------------------------------------------| -| 缺省 / 任意其他 Accept | `Content-Type: text/event-stream` | **默认**。每条 push 走 SSE 流式直推,前台主线程直接消化,iOS 不爆通知,断流时自动 fallback Web Push | +| 缺省 / 任意其他 Accept | `Content-Type: text/event-stream` | **默认**。每条 payload 走 SSE 流式直推,同时默认发送 Web Push backup;由 SW dedupe 防重复,断流/写入失败时也继续 fallback Web Push | | `Accept: application/json` | `Content-Type: application/json` | 显式 opt-out 回到 0.8.x 纯 Web Push 行为;HTTP 状态码 + JSON body 错误语义都保留 | `pushSubscription` 在两种模式下都**必填**——SSE 模式下用作断流 / 写入失败时的 fallback 通道。 @@ -112,7 +113,7 @@ const handler = createInstantHandler({ SSE wire format: ``` -: keepalive ← 每 15s 一行,防 CDN/proxy 超时 +: keepalive ← 默认 start 后立即发一次,之后每 1000ms 一行(可配,最小 250ms) event: payload ← 每条 push,data 是与 Web Push 通道一字节相同的 JSON data: {"messageKind":"reasoning","sessionId":"sess_...",...} @@ -131,6 +132,43 @@ data: {} > 客户端实现见 [`@rei-standard/amsg-client`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/client/README.md) 的 `consumeInstantStream()`。验证非 SSE 响应(含错误页 / 非 2xx)必须先看 `Content-Type` + status,再进 stream parser。 +#### SSE backup push(0.9.0+) + +正式环境推荐保持默认链路:SSE 正常流式返回,每条 payload enqueue 成功后也发一份 Web Push backup。它不是“断了才发”,而是 backup 常开;重复处理交给 `@rei-standard/amsg-sw` 的 delivery dedupe 解决。 + +| 配置 | 默认值 | 行为 | 生产建议 | +|------|--------|------|----------| +| `backupPush` | `'on'` | SSE payload enqueue 成功后,立即发送同 `messageId` 的 Web Push backup | 固定开启;`off` / `delayed` 会被拒绝,避免正式部署误入已知可能丢 payload 的模式 | +| `keepaliveMs` | `1000` | SSE 空闲 keepalive 间隔,最小 250ms | 保持默认 | +| `immediateKeepalive` | `true` | stream start 后立即发送第一条 keepalive | 保持默认 | + +显式写出来可以长这样;省略 `sse` 时也是这组默认值: + +```js +createInstantHandler({ + vapid: { ... }, + sse: { + backupPush: 'on', + keepaliveMs: 1_000, + immediateKeepalive: true, + } +}); +``` + +同一业务 payload 在 SSE 与 Web Push backup 中共用完全相同的 `messageId`。直接 Web Push、Blob envelope(会携带原 payload 的 `messageId` / `id` / `dedupeKey`)和 generic multipart 还原后的 payload 都能被 `@rei-standard/amsg-sw` 的 dedupe gate 识别。 + +新增 `onEvent` 事件: + +- `sse_payload_enqueued` +- `sse_payload_enqueue_failed` +- `sse_stream_aborted` +- `sse_stream_canceled` +- `backup_push_scheduled` +- `backup_push_sent` +- `backup_push_failed` +- `fallback_push_sent` +- `fallback_push_failed` + #### 请求 ```http @@ -594,7 +632,7 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): ### 生命周期 hooks `onBeforeLoop` / `onAfterLoop`(0.9.0+) -在 `onLLMOutput` 这个"per-turn 决策 hook"之外,0.9.0 加了一对**链路级** hook,给"主 LLM loop 跑的同时并行一些副任务(情绪评估、外部 webhook、统计上报…),结束后把结果作为额外 push 追加"这类需求一个干净的口子,不用把副任务塞进 `onLLMOutput` 里跟决策逻辑挤在一起。 +在 `onLLMOutput` 这个"per-turn 决策 hook"之外,0.9.0 加了一对**链路级** hook,给"主 LLM loop 跑的同时并行一些副任务(外部 webhook、统计上报、索引刷新…),结束后把结果作为额外 push 追加"这类需求一个干净的口子,不用把副任务塞进 `onLLMOutput` 里跟决策逻辑挤在一起。 ```ts createInstantHandler({ @@ -622,16 +660,16 @@ createInstantHandler({ ```js onBeforeLoop: ({ requestBody }) => ({ // 这些 promise 立刻就在跑了,跟主 LLM loop 并行 - emotion: runEmotionEval(requestBody), + lookup: runBackgroundLookup(requestBody), metrics: pushToAnalytics(requestBody), }), onAfterLoop: async ({ pending, deliver, sessionId }) => { - const { emotion } = pending; - const result = await emotion; // 主 loop 这时已经结束了 + const { lookup } = pending; + const result = await lookup; // 主 loop 这时已经结束了 if (result) { await deliver({ - messageKind: 'emotion_update', + messageKind: 'status_update', sessionId, data: result, }); // 作为额外一条 push 追加到本次链路 @@ -660,7 +698,7 @@ onAfterLoop: async ({ pending, deliver, sessionId }) => { ## Generic multipart transport(0.8.0+) -> **0.8.0 BREAKING**:旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除。`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,超限时都走同一套 generic `_multipart` transport。应用层不应该再监听或拼接 reasoning 半片。 +> **0.8.0 BREAKING**:旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除。`reasoning`、`tool_request`、`content`、`error`、`status_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,超限时都走同一套 generic `_multipart` transport。应用层不应该再监听或拼接 reasoning 半片。 发送优先级很简单: @@ -763,10 +801,10 @@ agentic loop 模式下 payload 大小分布(经验值): | 场景 | 常态 | p90 | p99 | |---|---|---|---| -| 纯文本 / 副作用 push,无 reasoning | 0.5–1.5 KB | 2 KB | 3 KB | -| 副作用 push + reasoning chain | 1–2.5 KB | 3 KB | 4–5 KB(撞线) | +| 纯文本 / 副作用 push,无 reasoning | 0.5–1.5 KB | 2 KB | 约 3000 B | +| 副作用 push + reasoning chain | 1–2.5 KB | 约 3000 B | 4–5 KB(撞线) | | tool-request push(带本轮 LLM 原文) | 0.8–1.8 KB | 2.5 KB | 3.5 KB | -| tool-request + reasoning | 1.5–3 KB | 4 KB(临界) | 5–6 KB(超) | +| tool-request + reasoning | 1.5 KB–约 3000 B | 4 KB(临界) | 5–6 KB(超) | → **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。0.8.0 阶段引入 [generic multipart transport](#generic-multipart-transport080) 后,没有 BlobStore 时也能透明拆分任意 JSON-safe payload;`BlobStore` 仍是更可靠方案,且优先级高于 multipart:超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, messageKind?, type? }`,SW / client 再按 envelope 约定读取真 body。 diff --git a/packages/rei-standard-amsg/instant/src/blob-store/interface.js b/packages/rei-standard-amsg/instant/src/blob-store/interface.js index c488146..a89314b 100644 --- a/packages/rei-standard-amsg/instant/src/blob-store/interface.js +++ b/packages/rei-standard-amsg/instant/src/blob-store/interface.js @@ -1,12 +1,12 @@ /** * BlobStore — pluggable transient store for "envelope-redirected" pushes. * - * Web Push has a hard ~3 KB safe-line on plaintext payload (see README - * §BlobStore for provenance). When the hook returns a large pushPayload - * (e.g. tool-request envelopes carrying replay history + reasoning), + * Web Push has a small plaintext payload budget (see README §BlobStore + * for provenance). When the hook returns a large pushPayload (e.g. + * tool-request envelopes carrying replay history + reasoning), * amsg-instant writes the body to a BlobStoreAdapter and pushes a - * ~200 B envelope `{ _blob:true, key, url, type? }` instead. The SW - * fetches `${url}` to recover the original body. + * small envelope `{ _blob:true, key, url, type? }` instead. The SW / + * client recovers the original body through that envelope contract. * * Adapter contract: * put(key, body, ttlSeconds) - durable until ttlSeconds elapses (or diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 16093a6..8a6ca98 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -48,6 +48,8 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- const SSE_ENCODER = new TextEncoder(); const SSE_KEEPALIVE_BYTES = SSE_ENCODER.encode(': keepalive\n\n'); const SSE_DONE_BYTES = SSE_ENCODER.encode('event: done\ndata: {}\n\n'); +const DEFAULT_SSE_KEEPALIVE_MS = 1000; +const MIN_SSE_KEEPALIVE_MS = 250; /** * @typedef {Object} VapidConfig @@ -137,6 +139,13 @@ const SSE_DONE_BYTES = SSE_ENCODER.encode('event: done\ndata: {}\n\n'); * `null` disables generic multipart only when `multipart` * is not explicitly configured. It no longer produces * reasoning-only `chunkIndex` / `totalChunks` wire fields. + * @property {Object} [sse] + * @property {'on'} [sse.backupPush='on'] + * - SSE always sends a Web Push backup after every successful + * enqueue. `off` / `delayed` are intentionally rejected so + * production cannot opt into known-lossy delivery modes. + * @property {number} [sse.keepaliveMs=1000] + * @property {boolean} [sse.immediateKeepalive=true] */ /** @@ -184,6 +193,7 @@ export function createInstantHandler(options) { // Eager validation keeps transport misconfiguration in startup logs / // unit tests instead of surprising the first oversized push. const multipart = resolveMultipartOptions(options); + const sse = resolveSseOptions(options.sse); // Validate VAPID shape eagerly so misconfiguration surfaces on the very // first request rather than the first Web Push attempt. @@ -380,70 +390,123 @@ export function createInstantHandler(options) { const startDone = new Promise((resolve) => { resolveStartDone = resolve; }); registerWaitUntil(startDone, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); + const backupWork = new Set(); + let streamUsable = true; + let keepaliveTimer = null; + let activeController = null; + + const stopKeepalive = () => { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + }; + const trackBackupWork = (work) => { + backupWork.add(work); + work.finally(() => { + backupWork.delete(work); + }); + }; + const messageIdOf = (body) => ( + body && typeof body === 'object' && typeof body.messageId === 'string' + ? body.messageId + : undefined + ); + const scheduleBackupPush = (body) => { + const messageId = messageIdOf(body); + onEvent({ type: 'backup_push_scheduled', sessionId, messageId }); + const work = (async () => { + try { + await sendPushWithMaybeBlob(body, payload, processorCtx, sessionId); + onEvent({ type: 'backup_push_sent', sessionId, messageId }); + } catch (pushErr) { + onEvent({ type: 'backup_push_failed', sessionId, messageId, cause: pushErr }); + } + })(); + trackBackupWork(work); + }; + const enqueueKeepalive = () => { + if (!streamUsable || request.signal.aborted || !activeController) return; + try { + activeController.enqueue(SSE_KEEPALIVE_BYTES); + } catch { + streamUsable = false; + stopKeepalive(); + } + }; + const startKeepalive = () => { + if (!streamUsable || request.signal.aborted) return; + if (sse.immediateKeepalive) enqueueKeepalive(); + if (!streamUsable || request.signal.aborted) return; + keepaliveTimer = setInterval(enqueueKeepalive, sse.keepaliveMs); + }; + const safeClose = () => { + // `controller.close()` throws TypeError if the stream is + // already errored (e.g. previous enqueue failed). We're + // exiting anyway — swallow. + try { activeController && activeController.close(); } catch { /* already closed/errored */ } + }; + return new Response( new ReadableStream({ async start(controller) { - let streamUsable = true; - let keepaliveTimer; - + activeController = controller; const onAbort = () => { + if (!streamUsable) return; streamUsable = false; - if (keepaliveTimer) clearInterval(keepaliveTimer); + stopKeepalive(); + onEvent({ type: 'sse_stream_aborted', sessionId }); }; request.signal.addEventListener('abort', onAbort); + if (request.signal.aborted) onAbort(); - const stopKeepalive = () => { - if (keepaliveTimer) { - clearInterval(keepaliveTimer); - keepaliveTimer = null; - } - }; - const startKeepalive = () => { - keepaliveTimer = setInterval(() => { - try { - controller.enqueue(SSE_KEEPALIVE_BYTES); - } catch { - stopKeepalive(); - } - }, 15000); - }; - const safeClose = () => { - // `controller.close()` throws TypeError if the stream is - // already errored (e.g. previous enqueue failed). We're - // exiting anyway — swallow. - try { controller.close(); } catch { /* already closed/errored */ } - }; const cleanup = () => { stopKeepalive(); request.signal.removeEventListener('abort', onAbort); }; - // Single transport boundary: try SSE first, fall back to - // Web Push on stream-gone OR enqueue failure. Used for - // both normal `event: payload` and `event: error`. + // Dual transport boundary. Two cases, NOT one: + // (1) Always-on backup: when SSE enqueue succeeds we ALSO + // call `scheduleBackupPush(stableBody)` so the same + // `messageId` ships on both SSE and Web Push. The SW + // / client dedupe gate collapses them back to a single + // business delivery (and at most one notification). + // (2) True fallback: when the stream is unusable (gone / + // aborted) or `controller.enqueue` throws, we skip SSE + // entirely and ship the payload via Web Push only. + // Used for both normal `event: payload` and `event: error`. const safeEnqueue = async (eventName, body, onFallbackFail) => { + const stableBody = ensureStableMessageId(body); + const messageId = messageIdOf(stableBody); const fallback = async () => { try { - await sendPushWithMaybeBlob(body, payload, processorCtx, sessionId); + await sendPushWithMaybeBlob(stableBody, payload, processorCtx, sessionId); + onEvent({ type: 'fallback_push_sent', sessionId, messageId, eventName }); } catch (pushErr) { + onEvent({ type: 'fallback_push_failed', sessionId, messageId, eventName, cause: pushErr }); if (onFallbackFail) onFallbackFail(pushErr); } }; - if (!streamUsable) { + if (!streamUsable || request.signal.aborted) { + streamUsable = false; + stopKeepalive(); await fallback(); return; } try { - controller.enqueue(SSE_ENCODER.encode(`event: ${eventName}\ndata: ${JSON.stringify(body)}\n\n`)); - } catch { + controller.enqueue(SSE_ENCODER.encode(`event: ${eventName}\ndata: ${JSON.stringify(stableBody)}\n\n`)); + onEvent({ type: 'sse_payload_enqueued', sessionId, messageId, eventName }); + scheduleBackupPush(stableBody); + } catch (err) { streamUsable = false; stopKeepalive(); + onEvent({ type: 'sse_payload_enqueue_failed', sessionId, messageId, eventName, cause: err }); await fallback(); } }; processorCtx.deliver = async (pushPayload) => { - await safeEnqueue('payload', ensureStableMessageId(pushPayload)); + await safeEnqueue('payload', pushPayload); }; startKeepalive(); @@ -479,8 +542,14 @@ export function createInstantHandler(options) { } } finally { cleanup(); + await Promise.allSettled(Array.from(backupWork)); resolveStartDone(); } + }, + cancel(reason) { + streamUsable = false; + stopKeepalive(); + onEvent({ type: 'sse_stream_canceled', sessionId, reason }); } }), { @@ -559,6 +628,32 @@ function resolveMultipartOptions(options) { }; } +function resolveSseOptions(input) { + const raw = input === undefined ? {} : input; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new TypeError('[amsg-instant] sse must be a plain object when set'); + } + + const backupPush = raw.backupPush === undefined ? 'on' : String(raw.backupPush); + if (backupPush !== 'on') { + throw new TypeError('[amsg-instant] sse.backupPush is always "on" in 0.9.0 stable'); + } + if (raw.backupDelayMs !== undefined) { + throw new TypeError('[amsg-instant] sse.backupDelayMs was removed; backup push is immediate'); + } + + const keepaliveMs = Math.max( + MIN_SSE_KEEPALIVE_MS, + resolvePositiveInt(raw.keepaliveMs, DEFAULT_SSE_KEEPALIVE_MS, 'sse.keepaliveMs') + ); + + return { + backupPush, + keepaliveMs, + immediateKeepalive: raw.immediateKeepalive !== false, + }; +} + function resolvePositiveInt(value, fallback, fieldName) { if (value === undefined || value === null) return fallback; if (!Number.isInteger(value) || value <= 0) { diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 04f8036..c26a4de 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -654,10 +654,8 @@ async function runAgenticLoop(payload, ctx) { } if (decision.decision === 'continue') { - // `nextHistory` REPLACES messages — that's the documented - // contract, even though most callers will want to do - // `[...ctx.messages, toolResult]`. README §"continue + - // nextHistory footgun" warns about this. + // `nextHistory` replaces the next-turn messages array. Callers + // that want append semantics must pass `[...ctx.messages, next]`. messages = Array.isArray(decision.nextHistory) ? decision.nextHistory.slice() : []; iteration++; continue; @@ -890,6 +888,12 @@ async function sendPushWithMaybeBlob(pushPayload, payload, ctx, sessionId) { messageKind: /** @type {{ messageKind?: unknown }} */ (payloadObj).messageKind, type: /** @type {{ type?: unknown }} */ (payloadObj).type, }; + for (const field of ['messageId', 'id', 'dedupeKey']) { + const value = /** @type {Record} */ (payloadObj)[field]; + if (typeof value === 'string' && value) { + envelope[field] = value; + } + } try { await sendWebPush({ diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 9ef286c..5e95068 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -38,6 +38,7 @@ import { decryptCapturedPushBody, base64UrlToBytes, consumeSse, + waitForPushCalls, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -1011,8 +1012,7 @@ describe('agentic loop — SSE: decision: finish', () => { assert.equal(payloads[0].messageIndex, 1); assert.equal(payloads[0].totalMessages, 1); assert.match(payloads[0].messageId, /^msg_[0-9a-f-]+$/); - // SSE direct delivery — no Web Push fallback. - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); }); @@ -1036,7 +1036,7 @@ describe('agentic loop — SSE: decision: tool-request', () => { assert.equal(payloads.length, 1); assert.equal(payloads[0].type, 'tool-request'); assert.equal(payloads[0].tool, 'get_weather'); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); }); @@ -1069,7 +1069,7 @@ describe('agentic loop — SSE: decision: continue → finish', () => { assert.equal(llmCalls, 2); assert.equal(payloads.length, 1); assert.equal(payloads[0].type, 'done'); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); }); @@ -1115,7 +1115,7 @@ describe('agentic loop — SSE: loop-exceeded', () => { assert.equal(payloads[0].messageKind, 'error'); assert.equal(payloads[0].code, 'LOOP_EXCEEDED'); assert.ok(events.find((e) => e.type === 'loop_exceeded')); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); }); @@ -1145,7 +1145,7 @@ describe('agentic loop — SSE: hook contract violations', () => { // would just double-trigger error handlers downstream. assert.equal(errors.length, 0); assert.ok(events.find((e) => e.type === 'hook_threw')); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); it('hook returns null → in-loop diagnostic only, no event: error', async () => { diff --git a/packages/rei-standard-amsg/instant/test/e2e.test.mjs b/packages/rei-standard-amsg/instant/test/e2e.test.mjs index 37f2798..a018ac7 100644 --- a/packages/rei-standard-amsg/instant/test/e2e.test.mjs +++ b/packages/rei-standard-amsg/instant/test/e2e.test.mjs @@ -1,11 +1,11 @@ /** - * E2E test for amsg-instant 0.8.0. + * E2E test for amsg-instant push payload compatibility. * * Verifies the push payload field shape produced by amsg-instant * matches the three-axis schema from @rei-standard/amsg-shared: * - messageKind: 'content' (with messageIndex / totalMessages set) - * - all 13 legacy 0.7.x fields still present (back-compat for the - * downstream SullyOS SW, which already grew handlers for them) + * - all 13 legacy 0.7.x fields still present for downstream SW + * back-compat * - plus new fields: messageKind, sessionId * * We intercept the outgoing Web Push HTTP request via `options.fetch`, @@ -24,6 +24,7 @@ import { decryptCapturedPushBody, makeLlmResponse, consumeSse, + waitForPushCalls, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -123,7 +124,7 @@ describe('e2e: push payload contract parity with amsg-server', () => { assert.equal(captured[0].sessionId, captured[1].sessionId); }); - it('SSE (default): streams the same ContentPush field shape over event: payload, no Web Push', async () => { + it('SSE (default): streams the same ContentPush shape and sends matching Web Push backups', async () => { const payload = buildPayload(); const router = createFetchRouter({ @@ -144,7 +145,7 @@ describe('e2e: push payload contract parity with amsg-server', () => { const { payloads, doneReceived } = await consumeSse(res); assert.equal(doneReceived, true); assert.equal(payloads.length, 2); - assert.equal(router.pushCalls.length, 0, 'SSE happy path must not fall back to Web Push'); + await waitForPushCalls(router, 2); const required = [ 'title', 'message', 'contactName', 'messageId', 'messageIndex', @@ -159,5 +160,12 @@ describe('e2e: push payload contract parity with amsg-server', () => { assert.equal(payloads[0].message, '第一句。'); assert.equal(payloads[1].message, '第二句!'); assert.equal(payloads[0].sessionId, payloads[1].sessionId); + + const backups = []; + for (const call of router.pushCalls) { + backups.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + assert.equal(backups[0].messageId, payloads[0].messageId); + assert.equal(backups[1].messageId, payloads[1].messageId); }); }); diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index 9eb28ff..a8f1739 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -12,6 +12,7 @@ import { decryptCapturedPushBody, makeLlmResponse, consumeSse, + waitForPushCalls, } from './helpers.mjs'; const ACCEPT_JSON = { accept: 'application/json' }; @@ -402,8 +403,7 @@ describe('createInstantHandler — clientToken', () => { const { payloads, doneReceived } = await consumeSse(res); assert.equal(payloads.length, 1); assert.equal(doneReceived, true); - // SSE happy path must not fall back to Web Push. - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); it('returns 401 INVALID_CLIENT_TOKEN when header missing', async () => { @@ -456,7 +456,7 @@ describe('createInstantHandler — clientToken', () => { const { payloads, doneReceived } = await consumeSse(res); assert.equal(payloads.length, 1); assert.equal(doneReceived, true); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); }); @@ -483,7 +483,7 @@ describe('createInstantHandler — happy path', () => { } }); - it('SSE (default): parses plaintext, calls LLM, splits, streams each sentence as event: payload, no Web Push', async () => { + it('SSE (default): parses plaintext, calls LLM, splits, streams each sentence, and sends Web Push backups', async () => { const router = llmRouter('你好。今天好天气!'); const handler = createInstantHandler({ vapid, fetch: router.fetch }); @@ -500,8 +500,7 @@ describe('createInstantHandler — happy path', () => { assert.equal(payloads[1].messageIndex, 2); // Same sessionId across the stream. assert.equal(payloads[0].sessionId, payloads[1].sessionId); - // SSE direct delivery — no Web Push fallback hit. - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 2); }); it('opt-out (Accept: application/json): returns LLM_CALL_FAILED on upstream error', async () => { @@ -548,10 +547,9 @@ describe('createInstantHandler — happy path', () => { assert.equal(body.error.code, 'PUSH_SEND_FAILED'); }); - it('SSE (default): push gateway failure is irrelevant — SSE writes succeed without fallback', async () => { - // In SSE happy path, the push gateway is never touched. We still wire - // it up as an always-failing endpoint to prove the handler does not - // silently fall back; the test asserts zero push calls AND a clean stream. + it('SSE (default): backup push gateway failure does not break a successful stream', async () => { + // Backup push is best-effort in SSE mode: the stream is still the primary + // response, while push failures are surfaced through events/logging hooks. const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: async () => makeLlmResponse('one sentence'), @@ -564,7 +562,7 @@ describe('createInstantHandler — happy path', () => { assert.equal(doneReceived, true); assert.equal(payloads.length, 1); assert.equal(errors.length, 0); - assert.equal(router.pushCalls.length, 0); + await waitForPushCalls(router, 1); }); it('rejects request when tokenSigningKey is set but Authorization missing', async () => { @@ -576,6 +574,189 @@ describe('createInstantHandler — happy path', () => { }); }); +describe('createInstantHandler — SSE backup push and stream lifecycle', () => { + it('defaults to sse.backupPush="on" and sends a WebPush backup with the same messageId', async () => { + const router = llmRouter('backup path.'); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (event) => events.push(event), + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const { payloads, doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + assert.equal(payloads.length, 1); + await waitForPushCalls(router, 1); + + const backup = JSON.parse(await decryptCapturedPushBody(router.pushCalls[0].body, subKit)); + assert.equal(backup.messageId, payloads[0].messageId); + assert.ok(events.some((event) => event.type === 'sse_payload_enqueued')); + assert.ok(events.some((event) => event.type === 'backup_push_scheduled')); + assert.ok(events.some((event) => event.type === 'backup_push_sent')); + }); + + it('rejects lossy sse.backupPush modes', async () => { + assert.throws( + () => createInstantHandler({ vapid, sse: { backupPush: 'off' } }), + /sse\.backupPush is always "on"/ + ); + assert.throws( + () => createInstantHandler({ vapid, sse: { backupPush: 'delayed' } }), + /sse\.backupPush is always "on"/ + ); + }); + + it('rejects removed sse.backupDelayMs knob', async () => { + assert.throws( + () => createInstantHandler({ vapid, sse: { backupDelayMs: 25 } }), + /sse\.backupDelayMs was removed/ + ); + }); + + it('SSE enqueue failure falls back to WebPush and records fallback events', async () => { + const router = llmRouter('fallback path.'); + const events = []; + const waitUntilPromises = []; + const originalEncode = TextEncoder.prototype.encode; + TextEncoder.prototype.encode = function encode(input) { + if (typeof input === 'string' && input.startsWith('event: payload')) { + throw new Error('test enqueue failure'); + } + return originalEncode.call(this, input); + }; + + try { + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + onEvent: (event) => events.push(event), + }); + const res = await handler(makeRequest({ body: makeValidPayload() })); + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + await waitUntilPromises[0]; + } finally { + TextEncoder.prototype.encode = originalEncode; + } + + await waitForPushCalls(router, 1); + const pushed = JSON.parse(await decryptCapturedPushBody(router.pushCalls[0].body, subKit)); + assert.equal(pushed.messageKind, 'content'); + assert.ok(events.some((event) => event.type === 'sse_payload_enqueue_failed')); + assert.ok(events.some((event) => event.type === 'fallback_push_sent')); + }); + + it('request.signal abort before payload delivery routes payload to fallback WebPush', async () => { + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { resolveLlm = () => resolve(makeLlmResponse('aborted path.')); }), + }); + const events = []; + const waitUntilPromises = []; + const controller = new AbortController(); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + onEvent: (event) => events.push(event), + }); + const req = new Request('http://localhost/instant', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(makeValidPayload()), + signal: controller.signal, + }); + + const res = await handler(req); + assert.equal(res.status, 200); + controller.abort(); + resolveLlm(); + await waitUntilPromises[0]; + + await waitForPushCalls(router, 1); + assert.ok(events.some((event) => event.type === 'sse_stream_aborted')); + assert.ok(events.some((event) => event.type === 'fallback_push_sent')); + }); + + it('stream cancel before payload delivery routes payload to fallback WebPush', async () => { + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { resolveLlm = () => resolve(makeLlmResponse('canceled path.')); }), + }); + const events = []; + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + onEvent: (event) => events.push(event), + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + await res.body.cancel('tab closed'); + resolveLlm(); + await waitUntilPromises[0]; + + await waitForPushCalls(router, 1); + assert.ok(events.some((event) => event.type === 'sse_stream_canceled' && event.reason === 'tab closed')); + assert.ok(events.some((event) => event.type === 'fallback_push_sent')); + }); + + it('immediateKeepalive enqueues a heartbeat as the first SSE chunk', async () => { + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { resolveLlm = () => resolve(makeLlmResponse('keepalive path.')); }), + }); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + sse: { immediateKeepalive: true, keepaliveMs: 1000 }, + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const reader = res.body.getReader(); + const first = await reader.read(); + assert.equal(new TextDecoder().decode(first.value), ': keepalive\n\n'); + await reader.cancel('done testing keepalive'); + resolveLlm(); + await waitUntilPromises[0]; + }); + + it('keepaliveMs is configurable and clamped to the 250ms minimum', async () => { + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { resolveLlm = () => resolve(makeLlmResponse('clamp path.')); }), + }); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + sse: { immediateKeepalive: false, keepaliveMs: 100 }, + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const reader = res.body.getReader(); + const startedAt = Date.now(); + const first = await reader.read(); + const elapsed = Date.now() - startedAt; + assert.equal(new TextDecoder().decode(first.value), ': keepalive\n\n'); + assert.ok(elapsed >= 180, `expected clamped keepalive delay, got ${elapsed}ms`); + await reader.cancel('done testing clamp'); + resolveLlm(); + await waitUntilPromises[0]; + }); +}); + describe('createInstantHandler — messages array forwarding (0.5.0)', () => { function captureLlmBody() { const captured = {}; diff --git a/packages/rei-standard-amsg/instant/test/helpers.mjs b/packages/rei-standard-amsg/instant/test/helpers.mjs index 22acfd4..8cc86ba 100644 --- a/packages/rei-standard-amsg/instant/test/helpers.mjs +++ b/packages/rei-standard-amsg/instant/test/helpers.mjs @@ -184,6 +184,16 @@ export function createFetchRouter(routes) { return { fetch: fetchImpl, pushCalls }; } +export async function waitForPushCalls(router, count, timeoutMs = 1000) { + const deadline = Date.now() + timeoutMs; + while (router.pushCalls.length < count && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (router.pushCalls.length !== count) { + throw new Error(`expected ${count} push call(s), got ${router.pushCalls.length}`); + } +} + /** * Convenience: build a fake LLM response with the given content. Any * `extra` keys are merged onto `choices[0].message`, so callers can From 4d3b7412353781754b7e0a405f36feb6ba2e59a2 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:18:06 +0800 Subject: [PATCH 6/9] feat(amsg-client): add consumeInstantStream and configurable maxPayloadBytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add consumeInstantStream(payload, endpointPath?, options) — SSE consumer for amsg-instant 0.9.0+. Parses event: payload / error / done and dispatches to options.onPayload. Encryption / cleartext transports share the same constructor config as sendInstant(). - Remove default 3KB payload size cap. Web Push body limits are handled by amsg-instant's BlobStore / multipart path; client only keeps avatarUrl soft-clear to prevent base64 avatars blowing up push delivery. - New constructor option maxPayloadBytes?: number | null. Default null (no SDK-level cap); set explicitly to enable PAYLOAD_TOO_LARGE_LOCAL preflight. - README updated to describe SSE + always-on Web Push backup + dedupe topology accurately ('fallback' now strictly means stream-unusable / enqueue-throw, not the always-on backup itself). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/client/CHANGELOG.md | 26 ++++++++-- packages/rei-standard-amsg/client/README.md | 33 +++++++++---- .../rei-standard-amsg/client/src/index.js | 47 ++++++++++--------- .../client/test/exports.test.mjs | 43 +++++++++++++++++ 4 files changed, 114 insertions(+), 35 deletions(-) diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index ae9de29..0a91728 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog — @rei-standard/amsg-client +## 2.4.0 — `consumeInstantStream()` SSE consumer + +配套 `@rei-standard/amsg-instant@0.9.0` 的 SSE 默认模式;同时移除 client 默认请求体大小上限,避免本地误拦长上下文请求。 + +### New + +- 新增 `consumeInstantStream(payload, endpointPath?, options)`,按 SSE frame 解析 `event: payload` / `event: error` / `event: done`,并分发到 `options.onPayload`。 +- 新增构造器选项 `maxPayloadBytes?: number | null`。默认 `null`,不再由 client 对请求体大小做本地限制;显式配置后,超限请求仍抛 `PAYLOAD_TOO_LARGE_LOCAL`。 +- `@rei-standard/amsg-shared` 精确依赖升级到 `0.2.0`,同步 `notification.silent` 类型/校验能力。 + +### Changed + +- 移除默认请求体大小上限。Web Push 单条回复超限仍由 `amsg-instant` 的 BlobStore / multipart 输出链路处理;client 只保留 `avatarUrl` 软清空,避免 data URI 头像把最终 push 撑爆。 + +### Docs + +- `consumeInstantStream` 章节校正:原文写 "SSE 写失败 / 断开才 fallback push",但 `amsg-instant 0.9.0` 起 Web Push backup 是 **always-on**——SSE 成功 enqueue 也照样发一份同 `messageId` 的 backup,由 SW / client dedupe 收敛。README 改成 "SSE 直送 + Web Push always-on backup + dedupe" 的双路语义;"fallback" 在文档里收窄回它本来该指代的含义(stream 不可用 / enqueue 抛错时的兜底)。仅文档,行为不变。 + ## 2.4.0-next.0 — `consumeInstantStream()` SSE consumer (pre-release) 发布在 `next` dist-tag。配套 `@rei-standard/amsg-instant@0.9.0-next.0+` 的 SSE 默认模式;老的 `sendInstant()` 字节级不变。 @@ -34,7 +52,7 @@ await client.consumeInstantStream(payload, '/instant', { ## 2.3.0-next.1 — avatarUrl 本地软清空 (pre-release) -Cherry-pick stable `2.2.4` 的本地 `avatarUrl` 软清空到 next 预发布线。`scheduleMessage` / `sendInstant` / `updateMessage` 不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串)改为 `console.warn` + 在 payload 上置 `null`(`updateMessage` 路径走 `delete` 以保留服务端原头像),请求继续发送。`Error.code === 'INVALID_AVATAR_URL_LOCAL'` 已移除;`PAYLOAD_TOO_LARGE_LOCAL`(3KB 体积上限)保留不变。详见 `2.2.4` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 +Cherry-pick stable `2.2.4` 的本地 `avatarUrl` 软清空到 next 预发布线。`scheduleMessage` / `sendInstant` / `updateMessage` 不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串)改为 `console.warn` + 在 payload 上置 `null`(`updateMessage` 路径走 `delete` 以保留服务端原头像),请求继续发送。`Error.code === 'INVALID_AVATAR_URL_LOCAL'` 已移除;当时版本的本地请求体体积预检保留不变,稳定版 2.4.0 已改为可选 `maxPayloadBytes` 且默认不限制。详见 `2.2.4` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 `next.0` → `next.1` 行为变化只此一项;shared push types re-exports 部分**完全不动**。 @@ -74,15 +92,15 @@ One import surface — caller apps that consume `ReiClient` and also handle push ### Fix -- **本地预校验 `avatarUrl` + payload 体积**(配合 [`@rei-standard/amsg-instant` 0.6.1](../instant/CHANGELOG.md#061--2026-05-18) / [`@rei-standard/amsg-server` 2.3.1](../server/CHANGELOG.md#231--2026-05-18)):之前 `scheduleMessage` / `sendInstant` / `updateMessage` 是纯 payload-agnostic 透传,业务把 `data:image/...;base64,xxx` 当 `avatarUrl` 传进来,client 会先 AES-GCM 加密、再 POST 出去,绕一圈才在远端拿到 `413` 或 Web Push 4KB 上限报错。现在三个方法在发请求之前做两项本地预检: +- **本地预校验 `avatarUrl` + payload 体积**(配合 [`@rei-standard/amsg-instant` 0.6.1](../instant/CHANGELOG.md#061--2026-05-18) / [`@rei-standard/amsg-server` 2.3.1](../server/CHANGELOG.md#231--2026-05-18)):之前 `scheduleMessage` / `sendInstant` / `updateMessage` 是纯 payload-agnostic 透传,业务把 `data:image/...;base64,xxx` 当 `avatarUrl` 传进来,client 会先 AES-GCM 加密、再 POST 出去,绕一圈才在远端拿到 `413` 或 Web Push 4KB 上限报错。当时三个方法在发请求之前做两项本地预检;稳定版 2.4.0 已把请求体体积预检改为可选 `maxPayloadBytes`,默认不限制: - **avatarUrl**:拒 `data:` URI、拒长度 > 2048 字符、必须是字符串。违规 → 抛 `Error` with `.code === 'INVALID_AVATAR_URL_LOCAL'`。 - - **payload 体积**:`JSON.stringify(payload)` 的 UTF-8 字节数 > 3072 → 抛 `Error` with `.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,附 `.details = { actualBytes, limitBytes, method }`。3KB 阈值是 Web Push 4KB 硬上限和典型网关 413 阈值往下留的余量;正常 payload(含多轮 messages、apiKey、push subscription)通常 1–2KB。 + - **payload 体积**:超过当时内置本地阈值会抛 `Error` with `.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,附 `.details = { actualBytes, limitBytes, method }`。此固定阈值在 2.4.0 起不再默认启用。 - 两个 code 都带 `LOCAL` 后缀,方便业务和远端返回的 `INVALID_PARAMETERS` / `INVALID_PAYLOAD_FORMAT` 区分(一个不耗远端配额,一个耗)。 - 错误 message 只写「是什么 + 怎么改」(如「头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL」),不写「为什么」—— 触发原因写在本 CHANGELOG / README,避免错误对话框塞一整段背景说明。 ### Compatibility -- 业务**几乎零修改**:除非之前真的在传 `data:` URI 当 avatarUrl 或传 > 3KB 的 payload(那本来就跑不通),否则升级无感。 +- 业务**几乎零修改**:除非之前真的在传 `data:` URI 当 avatarUrl,或命中了当时版本的固定本地体积预检,否则升级无感。 - 加密格式、headers、endpoint、响应 schema 全部不动。 - `scheduleMessage` / `sendInstant` / `updateMessage` 的返回类型不变;新增的两类错误**只在抛出时**才出现。 diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index 979186d..ec256ef 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -2,9 +2,9 @@ `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。 -## v2.4.0-next.0 — SSE consumer +## v2.4.0 — SSE consumer -新增 `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()` 字节级不变;老调用方升级零成本。 +新增 `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)。请求体默认不再由 client 做本地体积限制;需要本地护栏时可在构造器传 `maxPayloadBytes`。 ## v2.3.0 — Shared push types @@ -179,14 +179,14 @@ await client.sendInstant({ `splitPattern` 类型是 `string | string[]`。`scheduleMessage` 也支持,`updateMessage` 可显式传 `splitPattern: null` 重置回默认。client SDK 完全透传不校验,所有错误在 Worker / Server 端返回(每项 ≤ 200 字符、数组 ≤ 10 项、必须能 `new RegExp()` 通过)。 -**两个常见 footgun**: +**两个常见坑**: - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。 - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 ### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+) -`sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,省掉 push service → SW → IDB → window 的绕路,前台延迟从约 1–3s 降到次百毫秒。前台场景应该改用 `consumeInstantStream()`。 +`sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,前台延迟从约 1–3s(push service → SW → IDB → window)降到次百毫秒。Web Push backup 同时**常开 always-on**(即使 SSE enqueue 成功也照样发一份),用 SW / client 端按 `messageId` 做 dedupe 把两路收敛回一次。前台场景应该改用 `consumeInstantStream()`。 ```js const abort = new AbortController(); @@ -208,7 +208,12 @@ try { } ``` -请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`:SSE 写失败或客户端断开时 amsg-instant 用它做 best-effort fallback push(同一 `messageId`,客户端按 ID 幂等去重即可)。 +请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`。两条投递路径同时跑: + +1. **SSE 直送**(首选)——payload 走 `event: payload` 直接到 `onPayload`。 +2. **Web Push always-on backup**——成功 enqueue 的 payload 也会通过 `pushSubscription` 发一份;SSE 写失败 / 客户端断开 / enqueue throw 时也走这条路兜底。 + +同一 `messageId` 两路都到,由 SW 的 dedupe gate 或客户端按 ID 幂等去重收敛成一次业务投递与一次(必要时的)通知。 #### 错误语义 @@ -218,25 +223,33 @@ try { `endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。 -### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+) +### 本地软清空:`avatarUrl` 与可选 payload 体积上限(2.2.4+ / 2.4.0+) -`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护: +`scheduleMessage` / `sendInstant` / `consumeInstantStream` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;如果你希望在 SDK 本地先挡住过大的请求,可以在构造器显式传 `maxPayloadBytes`: + +```js +const client = new ReiClient({ + baseUrl: '/api/v1', + userId, + maxPayloadBytes: 256_000, // 可选;默认 null / 不限制 +}); +``` | 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) | | --- | --- | --- | | `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`,请求照发(`updateMessage` 从 patch 里删除该字段,保留服务端原头像) | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 | | `payload.avatarUrl` 长度 > 2048 字符 | 同上 | 同上。建议用 CDN 缩略图 URL。 | | `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 | -| `JSON.stringify(payload)` UTF-8 字节数 > 3072 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 远端网关 / Web Push 4KB 硬上限的本地兜底。 | +| 已配置 `maxPayloadBytes`,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 只在调用方主动需要本地请求体护栏时启用。Web Push 单条回复超限由 `amsg-instant` 的 BlobStore / multipart 输出链路处理,不靠 client 限制请求体。 | -头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。`PAYLOAD_TOO_LARGE_LOCAL` 仍然是真正的"整包过大"信号,照常用 try/catch 捕获: +头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。未配置 `maxPayloadBytes` 时不会产生 `PAYLOAD_TOO_LARGE_LOCAL`;配置后照常用 try/catch 捕获: ```js try { await client.sendInstant(payload); } catch (err) { if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') { - // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 } + // err.details = { method: 'sendInstant', actualBytes: 87320, limitBytes: 256000 } } else { throw err; } diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 9e37e75..406447b 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -59,6 +59,9 @@ * a *weak* shared secret — it ships inside any * frontend bundle that uses it, so devtools can * read it. Use for casual URL-direct abuse only. + * @property {number|null} [maxPayloadBytes=null] - Optional local UTF-8 byte cap for outgoing request + * payloads before encryption. `null` / omitted means + * no SDK-level request-size limit. */ /** @@ -69,15 +72,6 @@ */ const AVATAR_URL_MAX_LENGTH = 2048; -/** - * Max byte length of a single outgoing payload (3 KB, measured pre-encryption - * on the plaintext JSON body). Anything over this is almost certainly a base64 - * avatar smuggled into `avatarUrl` and will trigger downstream `413 Payload - * Too Large` or hit the Web Push 4 KB hard limit at delivery. We bail locally - * to save a remote round-trip and give a precise error. - */ -const PAYLOAD_LOCAL_MAX_BYTES = 3072; - function makeLocalError(code, message, details) { const err = new Error(`[rei-standard-amsg-client] ${message}`); err.code = code; @@ -120,6 +114,8 @@ export class ReiClient { this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken ? config.instantClientToken : ''; + /** @private */ + this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes); } /** @@ -181,8 +177,8 @@ export class ReiClient { * * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the * client soft-strips it on the payload and emits a `console.warn` — the - * schedule still ships, just without an avatar. The only throw left is - * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. + * schedule still ships, just without an avatar. If `maxPayloadBytes` is + * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`. * * @param {Object} payload - Schedule message payload. * @returns {Promise} API response body. @@ -230,8 +226,8 @@ export class ReiClient { * * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the * client soft-strips it on the payload and emits a `console.warn` — the - * push still ships, just without an icon. The only throw left is - * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. + * push still ships, just without an icon. If `maxPayloadBytes` is + * configured, oversized JSON payloads throw `PAYLOAD_TOO_LARGE_LOCAL`. * * @param {Object} payload - Instant message payload. * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'. @@ -428,8 +424,8 @@ export class ReiClient { * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), * the client soft-strips it from the patch and emits a `console.warn` — * the rest of the update still applies, and the stored avatar is left - * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` — - * JSON-serialized updates exceed 3 KB. + * untouched. If `maxPayloadBytes` is configured, oversized JSON patches + * throw `PAYLOAD_TOO_LARGE_LOCAL`. * * @param {string} uuid - Task UUID. * @param {Object} updates - Fields to update. @@ -567,21 +563,22 @@ export class ReiClient { } /** - * Reject outgoing payloads larger than 3 KB pre-encryption. Spares the - * remote a guaranteed 413 / Web Push 4 KB-limit failure and gives the - * caller a precise local error pointing at the size cap. + * Enforce the optional local request payload cap before encryption. + * By default there is no SDK-level request-size limit; runtime, proxy, + * database, and LLM-provider limits remain the deployer's boundary. * * @private * @param {string} bodyJson - `JSON.stringify(payload)`. * @param {string} methodName */ _assertPayloadSize(bodyJson, methodName) { + if (this._maxPayloadBytes == null) return; const bytes = new TextEncoder().encode(bodyJson).length; - if (bytes > PAYLOAD_LOCAL_MAX_BYTES) { + if (bytes > this._maxPayloadBytes) { throw makeLocalError( 'PAYLOAD_TOO_LARGE_LOCAL', - `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${PAYLOAD_LOCAL_MAX_BYTES} 字节`, - { method: methodName, actualBytes: bytes, limitBytes: PAYLOAD_LOCAL_MAX_BYTES } + `${methodName} payload 体积 ${bytes} 字节超过本地上限 ${this._maxPayloadBytes} 字节`, + { method: methodName, actualBytes: bytes, limitBytes: this._maxPayloadBytes } ); } } @@ -674,6 +671,14 @@ export class ReiClient { } } +function normalizeMaxPayloadBytes(value) { + if (value === undefined || value === null) return null; + if (!Number.isInteger(value) || value <= 0) { + throw new TypeError('[rei-standard-amsg-client] maxPayloadBytes must be a positive integer when set'); + } + return value; +} + export { MESSAGE_KIND, MESSAGE_TYPE, diff --git a/packages/rei-standard-amsg/client/test/exports.test.mjs b/packages/rei-standard-amsg/client/test/exports.test.mjs index b94a249..fd3056d 100644 --- a/packages/rei-standard-amsg/client/test/exports.test.mjs +++ b/packages/rei-standard-amsg/client/test/exports.test.mjs @@ -91,3 +91,46 @@ test('ReiClient constructs without throwing', () => { }); }); }); + +test('ReiClient has no request payload size cap by default', () => { + const client = new ReiClient({ + baseUrl: 'https://example.com', + instantEncryption: false, + }); + + assert.doesNotThrow(() => { + client._assertPayloadSize('x'.repeat(70_000), 'sendInstant'); + }); +}); + +test('ReiClient maxPayloadBytes opt-in cap throws PAYLOAD_TOO_LARGE_LOCAL', () => { + const client = new ReiClient({ + baseUrl: 'https://example.com', + instantEncryption: false, + maxPayloadBytes: 10, + }); + + assert.throws( + () => client._assertPayloadSize('x'.repeat(11), 'sendInstant'), + (err) => { + assert.equal(err.code, 'PAYLOAD_TOO_LARGE_LOCAL'); + assert.deepEqual(err.details, { + method: 'sendInstant', + actualBytes: 11, + limitBytes: 10, + }); + return true; + } + ); +}); + +test('ReiClient rejects invalid maxPayloadBytes config', () => { + assert.throws( + () => new ReiClient({ + baseUrl: 'https://example.com', + instantEncryption: false, + maxPayloadBytes: 0, + }), + /maxPayloadBytes must be a positive integer/ + ); +}); From a7f73a97372e51d2628a366e00acf11638010b0d Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:18:15 +0800 Subject: [PATCH 7/9] chore(release): bump amsg packages to 0.2.0 / 2.2.0 / 0.9.0 / 2.4.0 / 2.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bumps: - @rei-standard/amsg-shared: 0.1.0 → 0.2.0 - @rei-standard/amsg-sw: 2.1.1 → 2.2.0 - @rei-standard/amsg-instant: 0.9.0-next.1 → 0.9.0 - @rei-standard/amsg-client: 2.4.0-next.0 → 2.4.0 - @rei-standard/amsg-server: 2.4.1 → 2.5.0 (shared dep bump only, no behavior change) - bump.mjs updated to current targets - package-lock.json refreshed - repository URLs normalized to git+https://...git form across packages - server CHANGELOG records the shared 0.2.0 dependency bump - root + amsg workspace README tables updated with new versions and feature blurbs Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 26 +++++++++---------- bump.mjs | 10 +++---- package-lock.json | 18 ++++++------- packages/rei-standard-amsg/README.md | 11 ++++---- .../rei-standard-amsg/client/package.json | 6 ++--- .../rei-standard-amsg/instant/package.json | 6 ++--- .../rei-standard-amsg/server/CHANGELOG.md | 5 ++++ .../rei-standard-amsg/server/package.json | 6 ++--- .../rei-standard-amsg/shared/package.json | 4 +-- packages/rei-standard-amsg/sw/package.json | 6 ++--- 10 files changed, 52 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b199692..be7ea4f 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,27 @@ | 包 | 版本 | 用途 | |---|---|---| -| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.1.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | -| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.8.0` | 一次性即时推送(无 DB、无 cron、无租户) | -| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.4.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | -| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.3.0` | 浏览器 SDK:加密、请求封装、Push 订阅 | -| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.1.0` | Service Worker:推送展示、离线队列 | +| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.2.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | +| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.9.0` | 一次性即时推送(SSE 默认传输、always-on Web Push backup) | +| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.5.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | +| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.4.0` | 浏览器 SDK:加密、请求封装、Push 订阅、SSE consumer | +| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.2.0` | Service Worker:推送展示、离线队列、delivery dedupe | `amsg-shared` 是依赖图最底层:其他四个包都依赖它,反过来不行;它本身零运行时依赖。 **怎么挑服务端包**:只发"按钮点了就立刻推一条" → `amsg-instant`;要定时或周期任务 → `amsg-server`;两种都要就都装,共用同一套 VAPID 与 masterKey。 -### 协调发布说明:稳定版发布(shared 0.1.0 / instant 0.8.0 / sw 2.1.0 / client 2.3.0 / server 2.4.0) +### 协调发布说明:稳定版发布(shared 0.2.0 / instant 0.9.0 / sw 2.2.0 / client 2.4.0 / server 2.5.0) -本轮是一次跨包协调的 minor 升级,统一 push wire shape 到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器)。所有 amsg 子包同时上调一个 minor 并正式发布。 +本轮补上 SSE + Web Push backup 的同 key 去重链路,并将相关包作为稳定版发布。`amsg-server` 没有运行时行为改动,只做 shared 依赖协调发版。 -- `@rei-standard/amsg-shared` 新增 → `0.1.0` -- `@rei-standard/amsg-instant`:`0.7.0` → `0.8.0` -- `@rei-standard/amsg-server`:`2.3.2` → `2.4.0` -- `@rei-standard/amsg-sw`:`2.0.1` → `2.1.0` -- `@rei-standard/amsg-client`:`2.2.3` → `2.3.0` +- `@rei-standard/amsg-shared`:`0.1.0` → `0.2.0` +- `@rei-standard/amsg-instant`:`0.8.2` → `0.9.0` +- `@rei-standard/amsg-server`:`2.4.1` → `2.5.0` +- `@rei-standard/amsg-sw`:`2.1.1` → `2.2.0` +- `@rei-standard/amsg-client`:`2.3.0` → `2.4.0` -包间依赖一律使用**精确版本**(不带 `^`),所有 `dependencies` 字段都钉死在对应的版本,避免 npm 在生态系统里解析出混版本图。同时本轮移除了旧的 `{ type: 'error', code: '...' }` 错误信封——错误推送统一走 `ErrorPush`(`messageKind: 'error'`)。 +包间依赖一律使用**精确版本**(不带 `^`),避免 npm 在生态系统里解析出混版本图。本轮重点是:`amsg-instant` 默认 SSE 传输与 always-on Web Push backup、`amsg-client` 的 SSE consumer、`amsg-sw` 的 delivery dedupe / `REI_AMSG_DELIVER` bridge,以及 shared 的 `notification.silent` 类型补齐。 **安装最新版(`latest` dist-tag)**: diff --git a/bump.mjs b/bump.mjs index 81f9514..2053499 100644 --- a/bump.mjs +++ b/bump.mjs @@ -11,8 +11,8 @@ function updatePkg(pkgPath, version, sharedDep) { fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); } -updatePkg('packages/rei-standard-amsg/shared/package.json', '0.1.0', null); -updatePkg('packages/rei-standard-amsg/sw/package.json', '2.1.0', '0.1.0'); -updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0', '0.1.0'); -updatePkg('packages/rei-standard-amsg/client/package.json', '2.3.0', '0.1.0'); -updatePkg('packages/rei-standard-amsg/server/package.json', '2.4.0', '0.1.0'); +updatePkg('packages/rei-standard-amsg/shared/package.json', '0.2.0', null); +updatePkg('packages/rei-standard-amsg/sw/package.json', '2.2.0', '0.2.0'); +updatePkg('packages/rei-standard-amsg/instant/package.json', '0.9.0', '0.2.0'); +updatePkg('packages/rei-standard-amsg/client/package.json', '2.4.0', '0.2.0'); +updatePkg('packages/rei-standard-amsg/server/package.json', '2.5.0', '0.2.0'); diff --git a/package-lock.json b/package-lock.json index 690b80c..85d3cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,10 +1850,10 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.3.0", + "version": "2.4.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1865,10 +1865,10 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.2", + "version": "0.9.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1880,11 +1880,11 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.4.1", + "version": "2.5.0", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", - "@rei-standard/amsg-shared": "0.1.0", + "@rei-standard/amsg-shared": "0.2.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -1911,7 +1911,7 @@ }, "packages/rei-standard-amsg/shared": { "name": "@rei-standard/amsg-shared", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", @@ -1923,10 +1923,10 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/README.md b/packages/rei-standard-amsg/README.md index a3bb278..9e339ac 100644 --- a/packages/rei-standard-amsg/README.md +++ b/packages/rei-standard-amsg/README.md @@ -1,13 +1,14 @@ # ReiStandard AMSG SDK Workspace -主动消息能力的 SDK 工作区,4 个可发布的 npm 包。 +主动消息能力的 SDK 工作区,5 个可发布的 npm 包。 | Package | 版本 | 用途 | |---------|------|------| -| [`@rei-standard/amsg-instant`](./instant/README.md) | `0.6.1` | 一次性即时推送 handler(无 DB / 无 cron / 无租户) | -| [`@rei-standard/amsg-server`](./server/README.md) | `2.3.2` | 定时 + 周期消息:Blob 租户配置、token 鉴权、标准 handlers | -| [`@rei-standard/amsg-client`](./client/README.md) | `2.2.3` | 浏览器 SDK:加密、请求封装、Push 订阅 | -| [`@rei-standard/amsg-sw`](./sw/README.md) | `2.0.1` | Service Worker:推送展示、离线队列、后台重试 | +| [`@rei-standard/amsg-shared`](./shared/README.md) | `0.2.0` | 三轴推送契约、builders、类型守卫 | +| [`@rei-standard/amsg-instant`](./instant/README.md) | `0.9.0` | 一次性即时推送 handler(SSE 默认传输 / always-on Web Push backup) | +| [`@rei-standard/amsg-server`](./server/README.md) | `2.5.0` | 定时 + 周期消息:Blob 租户配置、token 鉴权、标准 handlers | +| [`@rei-standard/amsg-client`](./client/README.md) | `2.4.0` | 浏览器 SDK:加密、请求封装、Push 订阅、SSE consumer | +| [`@rei-standard/amsg-sw`](./sw/README.md) | `2.2.0` | Service Worker:推送展示、离线队列、delivery dedupe | **服务端选哪个**:只发"按钮触发 → 立刻推" 用 `amsg-instant`;要定时 / 周期任务 用 `amsg-server`;两种都要就都装,共用同一套 VAPID + masterKey。 diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index 492b003..c076ddf 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,10 +1,10 @@ { "name": "@rei-standard/amsg-client", - "version": "2.4.0-next.0", + "version": "2.4.0", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", - "url": "https://github.com/Tosd0/ReiStandard", + "url": "git+https://github.com/Tosd0/ReiStandard.git", "directory": "packages/rei-standard-amsg/client" }, "license": "MIT", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index a913875..95dced6 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,10 +1,10 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.9.0-next.1", + "version": "0.9.0", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", - "url": "https://github.com/Tosd0/ReiStandard", + "url": "git+https://github.com/Tosd0/ReiStandard.git", "directory": "packages/rei-standard-amsg/instant" }, "license": "MIT", @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index bc7650e..9a26205 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog — @rei-standard/amsg-server +## 2.5.0 — Dependency bump + +- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至稳定版 `0.2.0`,让正式发版环境不解析出混版本 shared graph。 +- 运行时行为不变;本包只是随 shared 的 `notification.silent` 类型/校验补齐做协调发版。 + ## 2.4.1 — readReasoningContent fallback - **Enhancement**: `readReasoningContent` 添加 fallback 支持。当原生 `reasoning_content` 字段缺失时,会 fallback 检查 `message.content` 是否包含 `...`、`...` 或 `...` 并提取,提供对更多模型(例如 DeepSeek-R1-Distill)的原生兼容。 diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index ccdfeb7..85b5dca 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,10 +1,10 @@ { "name": "@rei-standard/amsg-server", - "version": "2.4.1", + "version": "2.5.0", "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.", "repository": { "type": "git", - "url": "https://github.com/Tosd0/ReiStandard", + "url": "git+https://github.com/Tosd0/ReiStandard.git", "directory": "packages/rei-standard-amsg/server" }, "license": "MIT", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0", + "@rei-standard/amsg-shared": "0.2.0", "web-push": "^3.6.7", "@netlify/blobs": "^8.1.0" }, diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json index 1603bbf..a792384 100644 --- a/packages/rei-standard-amsg/shared/package.json +++ b/packages/rei-standard-amsg/shared/package.json @@ -1,10 +1,10 @@ { "name": "@rei-standard/amsg-shared", - "version": "0.1.0", + "version": "0.2.0", "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", "repository": { "type": "git", - "url": "https://github.com/Tosd0/ReiStandard", + "url": "git+https://github.com/Tosd0/ReiStandard.git", "directory": "packages/rei-standard-amsg/shared" }, "license": "MIT", diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index f655dc4..c18106f 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,10 +1,10 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.1", + "version": "2.2.0", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", - "url": "https://github.com/Tosd0/ReiStandard", + "url": "git+https://github.com/Tosd0/ReiStandard.git", "directory": "packages/rei-standard-amsg/sw" }, "license": "MIT", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0" + "@rei-standard/amsg-shared": "0.2.0" }, "devDependencies": { "tsup": "^8.0.0", From 1b0997c545b009e1fd6e424b2d5deee5a53ee52c Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:24:35 +0800 Subject: [PATCH 8/9] docs(amsg-sw): fix dedupe.dbName JSDoc typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note read "本包不维护跨 dbName 的迁移逻辑", which describes a non-existent IDB operation (each dbName is an independent IndexedDB instance). The actual reason storeName is locked down is that changing storeName under the same dbName needs version migration, which this package does not implement. Correct the note to "跨 storeName 的迁移 逻辑". Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/sw/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 39c0000..b2e2bfb 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -130,7 +130,7 @@ export const REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER; * @property {number} [dedupe.ttlMs=600000] * @property {number} [dedupe.cleanupIntervalMs=60000] * @property {(payload: any) => string | undefined} [dedupe.key] - * @property {string} [dedupe.dbName='rei_amsg_sw_dedupe_v1'] - 隔离去重数据用。每个 dbName 对应一个独立的 IndexedDB instance,互不影响。`dedupe.storeName` 不再可配(传了会抛错);本包不维护跨 dbName 的迁移逻辑。 + * @property {string} [dedupe.dbName='rei_amsg_sw_dedupe_v1'] - 隔离去重数据用。每个 dbName 对应一个独立的 IndexedDB instance,互不影响。`dedupe.storeName` 不再可配(传了会抛错);本包不维护跨 storeName 的迁移逻辑。 * @property {(payload: any) => void | Promise} [onBusinessPayload] * @property {(info: { key: string, source: string, messageKind?: string, firstSeenAt?: number, existingSource?: string, existingMessageKind?: string, existingNotificationShown?: boolean, duplicateNotificationShown?: boolean }) => void | Promise} [onDuplicate] */ From e3d2347430ee893b074d18d9bc76dfce72a8e60b Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:24:35 +0800 Subject: [PATCH 9/9] refactor(amsg-client): throw SSE error directly instead of return + finally re-throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'error' event branch in consumeInstantStream used to assign to a 'thrown' var and return, relying on the finally block to re-throw. Semantically equivalent to throwing directly, but reads like a successful Promise resolution — which is the opposite of the actual behavior. Throw the error inline; the outer catch still captures it into 'thrown' and finally still surfaces the same rejection. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/client/src/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 406447b..5354632 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -381,8 +381,7 @@ export class ReiClient { } const err = new Error(parsedErr.message || 'Stream error'); err.code = parsedErr.code; - thrown = err; - return; // exit loop, finally re-throws + throw err; } if (eventName === 'payload') {