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/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index aad3feb..0a91728 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,12 +1,58 @@ # 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()` 字节级不变。 + +### 新增 `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`。 ## 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 部分**完全不动**。 @@ -46,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 0ac83b3..ec256ef 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 — 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)。请求体默认不再由 client 做本地体积限制;需要本地护栏时可在构造器传 `maxPayloadBytes`。 + ## 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 用的便利出口。 @@ -175,30 +179,77 @@ 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+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 -### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+) +### 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` 直接打到主线程,前台延迟从约 1–3s(push service → SW → IDB → window)降到次百毫秒。Web Push backup 同时**常开 always-on**(即使 SSE enqueue 成功也照样发一份),用 SW / client 端按 `messageId` 做 dedupe 把两路收敛回一次。前台场景应该改用 `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`。两条投递路径同时跑: + +1. **SSE 直送**(首选)——payload 走 `event: payload` 直接到 `onPayload`。 +2. **Web Push always-on backup**——成功 enqueue 的 payload 也会通过 `pushSubscription` 发一份;SSE 写失败 / 客户端断开 / enqueue throw 时也走这条路兜底。 -`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护: +同一 `messageId` 两路都到,由 SW 的 dedupe gate 或客户端按 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.4.0+) + +`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/package.json b/packages/rei-standard-amsg/client/package.json index a5a272f..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.3.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/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 2c8ac8d..5354632 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'. @@ -273,14 +269,162 @@ 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; + throw err; + } + + 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. * * 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. @@ -418,21 +562,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 } ); } } @@ -525,6 +670,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/ + ); +}); diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 6284340..564e1ed 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,93 @@ # 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 分支没接生命周期保护的遗漏。 + +**问题**: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。** + +### 默认传输模式切换为 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 对象(例如 `{ lookup: runBackgroundLookup(...) }`,里面的 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)的原生兼容。 @@ -22,7 +110,7 @@ ## 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 76a5f69..3d87d62 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -37,8 +37,11 @@ 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)。 | +| `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。 | @@ -98,12 +101,81 @@ const handler = createInstantHandler({ ### HTTP 协议 -**请求**: +#### 传输模式协商(0.9.0+) + +| 请求头 | 响应 | 适用场景 | +|---------------------------------|-----------------------------------|-----------------------------------------------------| +| 缺省 / 任意其他 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 通道。 + +SSE wire format: + +``` +: keepalive ← 默认 start 后立即发一次,之后每 1000ms 一行(可配,最小 250ms) + +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。 + +#### 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 POST /instant Authorization: Bearer ← 仅当 tokenSigningKey 配置时检查 X-Client-Token: ← 仅当 clientToken 配置时检查 +Accept: application/json ← 可选;显式走纯 Web Push 路径(0.9.0+) Content-Type: application/json { @@ -198,7 +270,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 +284,6 @@ curl -X POST https://instant.example.com/instant \ } ``` -**响应**(失败): - ```json { "success": false, "error": { "code": "LLM_CALL_FAILED", "message": "..." } } ``` @@ -305,11 +377,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 +630,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 并行 + lookup: runBackgroundLookup(requestBody), + metrics: pushToAnalytics(requestBody), +}), + +onAfterLoop: async ({ pending, deliver, sessionId }) => { + const { lookup } = pending; + const result = await lookup; // 主 loop 这时已经结束了 + if (result) { + await deliver({ + messageKind: 'status_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` 二级嵌套): @@ -572,7 +698,7 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): ## 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 半片。 发送优先级很简单: @@ -675,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。 @@ -905,6 +1031,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 21ad9c8..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.8.2", + "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/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 bd968e0..8a6ca98 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,16 @@ 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'); +const DEFAULT_SSE_KEEPALIVE_MS = 1000; +const MIN_SSE_KEEPALIVE_MS = 250; + /** * @typedef {Object} VapidConfig * @property {string} email @@ -86,6 +98,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 @@ -123,6 +139,13 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * `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] */ /** @@ -157,6 +180,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 @@ -168,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. @@ -295,7 +321,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 +335,238 @@ 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; + + // 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); + + 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) { + activeController = controller; + const onAbort = () => { + if (!streamUsable) return; + streamUsable = false; + stopKeepalive(); + onEvent({ type: 'sse_stream_aborted', sessionId }); + }; + request.signal.addEventListener('abort', onAbort); + if (request.signal.aborted) onAbort(); + + const cleanup = () => { + stopKeepalive(); + request.signal.removeEventListener('abort', onAbort); + }; + + // 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(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 || request.signal.aborted) { + streamUsable = false; + stopKeepalive(); + await fallback(); + return; + } + try { + 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', 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(); + await Promise.allSettled(Array.from(backupWork)); + resolveStartDone(); + } + }, + cancel(reason) { + streamUsable = false; + stopKeepalive(); + onEvent({ type: 'sse_stream_canceled', sessionId, reason }); + } + }), + { + 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 || '内部错误' }, @@ -382,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 42876a0..c26a4de 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 }); } @@ -607,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; @@ -659,7 +704,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 }); } @@ -843,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({ @@ -978,4 +1029,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..5e95068 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,8 @@ import { createFetchRouter, decryptCapturedPushBody, base64UrlToBytes, + consumeSse, + waitForPushCalls, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -74,7 +76,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 +146,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 +389,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 +978,209 @@ 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-]+$/); + await waitForPushCalls(router, 1); + }); +}); + +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'); + await waitForPushCalls(router, 1); + }); +}); + +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'); + await waitForPushCalls(router, 1); + }); +}); + +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')); + await waitForPushCalls(router, 1); + }); +}); + +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')); + await waitForPushCalls(router, 1); + }); + + 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..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,7 +24,18 @@ before(async () => { subKit = await generateTestSubscription(); }); +// 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', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeSseRequest(body) { return new Request('https://worker.example.com/instant', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -128,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', () => { @@ -211,6 +247,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..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`, @@ -23,6 +23,8 @@ import { createFetchRouter, decryptCapturedPushBody, makeLlmResponse, + consumeSse, + waitForPushCalls, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -36,8 +38,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 +50,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 +63,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 +123,49 @@ 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 shape and sends matching Web Push backups', 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); + await waitForPushCalls(router, 2); + + 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); + + 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 bfe0f27..a8f1739 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -9,9 +9,14 @@ import { generateTestVapid, generateTestSubscription, createFetchRouter, + decryptCapturedPushBody, makeLlmResponse, + consumeSse, + waitForPushCalls, } from './helpers.mjs'; +const ACCEPT_JSON = { accept: 'application/json' }; + const LLM_URL = 'https://api.example.com/v1/chat/completions'; let vapid; @@ -381,14 +386,26 @@ 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); + await waitForPushCalls(router, 1); + }); + 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); + await waitForPushCalls(router, 1); + }); }); // ─── 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,88 @@ describe('createInstantHandler — happy path', () => { } }); - it('returns LLM_CALL_FAILED on upstream error', 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 }); + + 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); + await waitForPushCalls(router, 2); + }); + + 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): 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'), + 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); + await waitForPushCalls(router, 1); + }); + 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() })); @@ -482,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 c944133..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 @@ -201,4 +211,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), }); } 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/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/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/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`), 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/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", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 82fc620..b2e2bfb 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` 不再可配(传了会抛错);本包不维护跨 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] */ /** @@ -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` 钩子能力,安全拦截落地完整业务负载。