From 72f8c25a96e96c812b4c592ca213839d79474822 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:08:17 +0800 Subject: [PATCH 1/7] feat(amsg-sw): IndexedDB connection resilience + business-aware DELIVER ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @rei-standard/amsg-sw 2.2.0 → 2.3.0. - 连接被浏览器强制关闭(backing store 出错 / 存储压力 / 清数据)后自愈: 挂 onclose 清缓存 + 事务级一次重开兜底,dedupe 与 queue/multipart 库同等覆盖, 并发恢复时只剔除真正失效的那个连接。无需重启 SW。 - DELIVER ack 新增可选字段 businessError:onBusinessPayload 失败时透传,ok 维持 「已收下并分发」语义(非破坏,成功时不出现该字段)。businessError 在 dedupe 记录的读写路径上一致持久化(await 后一律读最新 + firstSeenAt 身份校验),不被 重复包、通知补写或 TTL 换人擦掉或张冠李戴。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + bump.mjs | 2 +- package-lock.json | 2 +- packages/rei-standard-amsg/sw/CHANGELOG.md | 8 + packages/rei-standard-amsg/sw/README.md | 42 +- packages/rei-standard-amsg/sw/package.json | 2 +- packages/rei-standard-amsg/sw/src/index.js | 270 ++++++++-- .../sw/test/connection-resilience.test.mjs | 240 +++++++++ .../sw/test/dispatch.test.mjs | 487 ++++++++++++++++++ .../sw/test/helpers/fake-indexeddb.mjs | 410 +++++++++++++++ 10 files changed, 1430 insertions(+), 36 deletions(-) create mode 100644 packages/rei-standard-amsg/sw/test/connection-resilience.test.mjs create mode 100644 packages/rei-standard-amsg/sw/test/helpers/fake-indexeddb.mjs diff --git a/.gitignore b/.gitignore index 99543e5..9ffc9af 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules/ # Editor / tooling .serena/ +# Claude Code local state (machine/session-specific) +.claude/ + # Build output packages/*/dist/ packages/*/*/dist/ diff --git a/bump.mjs b/bump.mjs index 2053499..360b692 100644 --- a/bump.mjs +++ b/bump.mjs @@ -12,7 +12,7 @@ function updatePkg(pkgPath, version, sharedDep) { } 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/sw/package.json', '2.3.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 85d3cbe..34bf24d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1923,7 +1923,7 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.2.0" diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index fbe26d1..e0d4747 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — @rei-standard/amsg-sw +## 2.3.0 — IndexedDB 连接韧性 + 业务感知的 DELIVER ack + +- **Fix**: IndexedDB 连接被浏览器**强制关闭**(backing store 出错 / 存储压力 / 清数据)后自愈。强关只触发 `close`、不触发 `versionchange`,此前缓存里的死连接会被无限复用,每次事务都抛 `InvalidStateError`,导致去重失灵、push 落库被阻断、`dedupe cleanup failed` 刷屏且不重启 SW 不恢复。dedupe 库与 queue / multipart 库(`cachedDB`)一并修复。 + - 给缓存连接挂 `onclose`:被强关时剔除缓存,下次访问重开。 + - 事务级一次重开兜底:`close` 事件可能晚于下一次事务、而 `db.transaction()` 同步抛错,故发事务命中「连接 closing/closed」时清缓存、重开一次、重试一次;重试上限 1 次,第二次仍失败如实抛出。 +- **New**: DELIVER ack 增加可选字段 `businessError`(非破坏)。`onBusinessPayload` reject 或抛错时,ack 仍是 `ok: true` 但带上 `businessError: `;成功时不出现该字段。`ok` 的含义明确为「已收下并分发」而非「业务已落库」,需要严格区分「传输成功 / 业务落库成功」的消费方读 `businessError` 即可。webpush `push` 路径无 ack,业务失败仅内部 `console.error`,不会让投递 promise reject。 + - 失败会持久化到 dedupe 记录上:之后**同 key 的重复包**(发送方重试 / 另一条 transport 的 backup)被去重后,ack 仍会带上首包的 `businessError`,而不是回一个看着干净的 `ok:true, duplicate:true`。注意:去重不会让 `onBusinessPayload` 重跑——这只是让信号诚实,不是补救机制;要「失败可重试」需消费方自己做幂等(见 README「在 SW 内执行 tool_request 的安全边界」)。 + ## 2.2.0 — delivery dedupe + SSE bridge - **New**: `installReiSW({ dedupe })` 新增通用 delivery dedupe,默认开启。默认 key 为 `payload.messageId` → `payload.id` → `payload.dedupeKey`,没有 key 时保持兼容不去重。 diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 869ea98..618a5c5 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -135,7 +135,7 @@ const registration = await navigator.serviceWorker.ready; const channel = new MessageChannel(); channel.port1.onmessage = (event) => { - // 成功:{ ok: true, duplicate?: boolean, key?: string, requestId?: string } + // 成功:{ ok: true, duplicate?: boolean, key?: string, requestId?: string, businessError?: string } // 失败:{ ok: false, error: string, key?: string, requestId?: string } }; @@ -149,6 +149,36 @@ registration.active?.postMessage({ Web Push `push` event 和 `REI_AMSG_DELIVER` 最终都会进入同一个内部 pipeline。SSE 先到时,后来的 Web Push backup 会被 dedupe;Web Push 先到时,后来的 SSE bridge 也会被 dedupe。若首包已经落过业务但没弹通知,重复包只负责按当前 `notification.show` 策略补通知,不会重复触发业务回调。 +#### ack 的 `ok` 表示「已收下并分发」,不表示「业务已落库」 + +DELIVER ack 的 `ok: true` 只代表 SW 收下了 payload 并完成了分发(窗口广播 + 通知策略),**不代表** `onBusinessPayload` 已经成功落库。如果业务回调 reject 或抛错,ack 仍然是 `ok: true`,但会带上一个可选的 `businessError` 字段(业务回调失败时填 `error.message`,成功时不出现这个字段): + +```js +channel.port1.onmessage = (event) => { + const { ok, duplicate, businessError } = event.data; + if (ok && businessError) { + // payload 已分发,但消费方落库失败 —— 由你决定是否重试 / 上报 + } +}; +``` + +这样设计是为了向后兼容:`ok` 的含义保持不变,原本只看 `ok` 的调用方不受影响;需要严格区分「传输成功」和「业务落库成功」的调用方读 `businessError` 即可。webpush `push` 路径没有 ack,业务回调失败只会在 SW 内部 `console.error`,不会让投递 promise reject。 + +`businessError` 会持久化到 dedupe 记录上:之后**同 key 的重复包**(发送方重试、或另一条 transport 的 backup)被去重后,ack 仍会带上首包的 `businessError`,不会回一个看着干净的 `{ ok:true, duplicate:true }` 把未解决的失败藏起来。但要注意——**去重不会让 `onBusinessPayload` 重跑**:这个字段只是把信号报准,不是补救机制。想让「失败的投递」能被重试真正修好,得消费方自己保证业务回调幂等(见下方「在 SW 内执行 tool_request 的安全边界」)。 + +#### 在 SW 内执行 tool_request 的安全边界 + +`onBusinessPayload` 里直接执行 `tool_request`(在 SW 里跑工具、回结果)是支持的常见用法。这里要理解清楚去重提供的保证和它的边界: + +- **正常情况下不会重复执行**:dedupe 是「先占坑、再跑业务」,所以同一个 `messageId` 在 TTL 窗口内(默认 10 分钟)`onBusinessPayload` **最多被调用一次**。SSE + Web Push backup 双路送达、push 服务重投递,重复的那些都会在跑业务之前被挡掉。换句话说,**dedupe 本身就是你的「执行一次」闸门**,普通场景你不需要再自己记账本。 +- **边界一:TTL**。这个「只执行一次」只保 TTL 那段时间。极少数超过 TTL 才发生的重投递会被当成新消息、重新执行。绝大多数重投递都在秒级~分钟级,10 分钟够用;要更死的保证就自己按 `id` 记一张永久「执行账本」。 +- **边界二:失败不回滚**。`onBusinessPayload` 失败时,dedupe 的坑**不会撤销**——同 key 重试会被去重、不会重跑业务(ack 会持续带上 `businessError` 提醒你「这条仍未解决」)。所以: + + - 如果你的工具有**真实副作用**(发邮件、下单、转账、加未读数、播声音……),**不要**指望「重发同一条」来补救失败——那只会被去重吞掉。让 `onBusinessPayload` 失败时把 `businessError` 报上去,由应用层用**新的 id / 新的请求**去重试,或自备幂等「执行账本」(执行前查 `id` 是否已执行)后再考虑开启失败回滚。 + - 如果你的业务回调是**纯幂等**(只按 `messageId` 覆盖写、工具可安全重跑),那重跑无害,怎么重试都行。 + +> 一句话:**普通成功路径,在 SW 里执行 tool_request 是安全的,dedupe 已经保证不重复执行**;只有当你需要「失败的工具自动重试」时,幂等才成为你的责任。 + ### 生产推荐链路:SSE + Web Push backup + SW dedupe 0.9.0 / 2.2.0 起,正式环境推荐把“双路投递、包层去重”当作默认责任边界。`amsg-instant` 固定 `backupPush:'on'`,所以 Worker 不需要等断线才发 backup;client 收到 SSE 后应立刻桥接给 SW;SW 负责统一去重、补通知和业务落地。 @@ -234,6 +264,15 @@ TTL 到期仍未收齐时,SW 会清理 pending 并广播: 业务应用只订阅普通事件即可。`content` multipart 收齐后照常弹通知;`reasoning` / `tool_request` / `error` 仍默认不弹通知。 +### IndexedDB 连接韧性(2.3.0+) + +dedupe 库、queue / multipart 库都把 IndexedDB 连接缓存复用。浏览器底层可能在存储压力、backing store 出错、用户清数据等情况下**强制关闭**这些连接——这种强关只触发 `close` 事件,不触发 `versionchange`。2.3.0 之前缓存里的死连接会被无限复用,之后每次事务都抛 `InvalidStateError`,导致去重失灵、push 落库被阻断、`dedupe cleanup failed` 刷屏,且不重启 SW 不会自愈。 + +2.3.0 起包内做了两层兜底,无需业务侧改动: + +- **`onclose` 清缓存**:连接被强关时把它从缓存里剔除,下次访问自动重开。 +- **事务级一次重开**:因为 `close` 事件可能晚于下一次事务调用、而 `db.transaction()` 是同步抛错,所以发事务时若命中「连接 closing/closed」,会清缓存、重开一次、重试一次;第二次仍失败才如实抛出(重试上限 1 次,不会无限循环)。 + ### 升级注意事项 - 想给 `reasoning` / `tool_request` / `error` 弹通知的业务:SW 默认不再为它们弹通知,但可以通过设置 `payload.notification.show = "always"` 或 `"when-hidden"` 来让 SW 在包层直接弹通知。无需再强求在 app 内自绘。 @@ -248,6 +287,7 @@ TTL 到期仍未收齐时,SW 会清理 pending 并广播: - 处理 `message` 事件:支持离线请求入队与主动冲刷队列 - 处理 `sync` 事件:在网络恢复后自动重试队列请求 - 使用 IndexedDB 存储待发送请求,避免页面关闭后丢失 +- IndexedDB 连接被浏览器强制关闭后自愈(`onclose` 清缓存 + 事务级一次重开),无需重启 SW > 注意:插件默认**不内置** `notificationclick` 逻辑,点击跳转策略由业务自行实现。 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index c18106f..7ab710e 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.2.0", + "version": "2.3.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", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index b2e2bfb..435640c 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -211,10 +211,21 @@ async function handlePushPayload(sw, payload, ctx) { const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx); claim.duplicateNotification = duplicateNotification; await notifyDuplicate(payload, claim, ctx); - return { ...claim, duplicateNotification }; + const result = { ...claim, duplicateNotification }; + // The first delivery claims this key and runs business at most once. If + // that business failed, the failure is persisted on the dedupe record — + // surface it so a retry/backup gets an honest ack, not a clean ok:true. + // Read the LATEST record, not the pre-await `claim.existing` snapshot: + // while we awaited the repair path above, an in-flight first delivery may + // have just persisted its businessError, which the stale snapshot misses. + const businessError = await readDuplicateBusinessError(claim, ctx); + if (businessError !== undefined) { + result.businessError = businessError; + } + return result; } - await dispatchBusinessPayload(sw, payload, { + const dispatchResult = await dispatchBusinessPayload(sw, payload, { defaultIcon: ctx.defaultIcon, defaultBadge: ctx.defaultBadge, onBusinessPayload: ctx.onBusinessPayload, @@ -225,6 +236,13 @@ async function handlePushPayload(sw, payload, ctx) { // hit `notificationStatePending` and skip the repair path. await updateDedupeNotificationState(claim, ctx, intermediateResult); }); + const businessError = dispatchResult ? dispatchResult.businessError : undefined; + if (businessError !== undefined) { + claim.businessError = businessError; + // Persist the failure on the dedupe record so later duplicates of this + // same key (a retry, or the other transport's backup) can report it too. + await updateDedupeBusinessState(claim, ctx, businessError); + } return claim; } @@ -238,12 +256,19 @@ async function handleDeliverMessage(sw, event, message, ctx) { ? message.source : 'message'; result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {}; - respondToSender(event, { + const ack = { ok: true, duplicate: Boolean(result.duplicate), key: result.key, requestId: message.requestId, - }); + }; + // `ok` means "received and dispatched", NOT "business persisted". When + // the consumer's onBusinessPayload failed, surface it without flipping + // `ok`, so existing callers keep working and stricter callers can react. + if (result.businessError !== undefined) { + ack.businessError = result.businessError; + } + respondToSender(event, ack); } catch (error) { respondToSender(event, { ok: false, @@ -295,15 +320,23 @@ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSett // notification. The overall waitUntil chain still awaits the business // callback below so the SW does not get killed mid-flight. let businessWork = null; + let businessError; if (typeof defaults.onBusinessPayload === 'function') { try { const result = defaults.onBusinessPayload(payload); if (result && typeof result.then === 'function') { - businessWork = Promise.resolve(result).catch(error => { - console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error); - }); + businessWork = Promise.resolve(result).then( + () => {}, + (error) => { + // Capture (do not swallow) the rejection so the DELIVER ack can + // reflect that the payload was dispatched but not persisted. + businessError = errorToMessage(error); + console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error); + } + ); } } catch (error) { + businessError = errorToMessage(error); console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error); } } @@ -315,6 +348,8 @@ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSett } if (businessWork) await businessWork; + // Resolved as `undefined` on success — callers only act when it is set. + settledResult.businessError = businessError; return settledResult; } @@ -585,6 +620,64 @@ async function updateDedupeNotificationState(claim, ctx, dispatchResult) { } } +/** + * Persist a business-callback failure onto the dedupe record so that later + * duplicates of the same key (a sender retry, or the other transport's + * backup) can report it on their ack. Business runs at most once per key, + * so this is the only place the failure can be remembered. + */ +async function updateDedupeBusinessState(claim, ctx, businessError) { + if (businessError === undefined) return; + if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return; + + try { + // Attach only to the very record we claimed. While our business callback + // ran, the stored record may have been: + // (a) repaired by a duplicate/backup — keep that by merging onto the + // LATEST record, not the first delivery's stale snapshot, so we + // don't flip `notificationShown` back and re-show a notification; or + // (b) replaced by a TTL-renewed claim (delete + re-add) — a fresh + // `firstSeenAt` means a different delivery now owns this key, and + // stamping our old failure onto it would mis-report that newer + // delivery (which may have succeeded). + const latest = await readDedupeRecord(ctx.dedupe, claim.key); + if (!latest || !claim.record || latest.firstSeenAt !== claim.record.firstSeenAt) return; + const next = { ...latest, key: claim.key, businessError }; + await putDedupeRecord(ctx.dedupe, next); + claim.record = next; + } catch (error) { + console.error('[rei-standard-amsg-sw] dedupe business state update failed:', error); + } +} + +/** + * Resolve the businessError to report on a duplicate's ack. Reads the latest + * persisted record (the first delivery's business may have failed and + * persisted it after this duplicate snapshotted `claim.existing`), falling + * back to that snapshot if the live read yields nothing. + */ +async function readDuplicateBusinessError(claim, ctx) { + const snapshot = claim && claim.existing ? claim.existing.businessError : undefined; + if (!ctx.dedupe || ctx.dedupe.enabled === false || !claim || !claim.key || !claim.existing) { + return snapshot; + } + try { + const latest = await readDedupeRecord(ctx.dedupe, claim.key); + // Trust the live record only if it is still the same claim we duplicated. + // A TTL-renewed claim (fresh `firstSeenAt`) belongs to a different, newer + // delivery, so reporting its businessError on this stale duplicate's ack + // would misattribute an unrelated failure. Mirrors the write path. + if (latest + && latest.firstSeenAt === claim.existing.firstSeenAt + && latest.businessError !== undefined) { + return latest.businessError; + } + } catch (_readError) { + // Fall back to the snapshot below. + } + return snapshot; +} + async function maybeShowDuplicateNotification(sw, payload, claim, ctx) { const existing = claim && claim.existing ? claim.existing : null; if (!existing || existing.notificationShown === true) { @@ -618,8 +711,14 @@ async function maybeShowDuplicateNotification(sw, payload, claim, ctx) { await sw.registration.showNotification(notification.title, notification.options); + // Merge onto the LATEST record, not the pre-await `existing` snapshot: + // while we awaited showNotification, the first delivery may have persisted + // a `businessError` (or other fields) onto this key. Overwriting from the + // stale snapshot would erase it and break the DELIVER ack contract. + const latest = await readDedupeRecord(ctx.dedupe, claim.key); + const base = latest || existing; const next = { - ...existing, + ...base, notificationShown: true, notificationStatePending: false, }; @@ -1110,6 +1209,10 @@ async function registerFlushSync(sw) { } } +function errorToMessage(error) { + return error instanceof Error ? error.message : String(error); +} + function respondToSender(event, message) { const messagePort = event.ports && event.ports[0]; if (messagePort && typeof messagePort.postMessage === 'function') { @@ -1330,24 +1433,107 @@ async function listStoreRecords(storeName) { }); } -async function withDatabaseStore(storeName, mode, handler) { - const db = await openQueueDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, mode); +/** + * True when an error means the IndexedDB connection we just used is + * closing/closed — i.e. the browser force-closed it (backing-store error, + * storage pressure, user clearing data) and any `transaction()` on it + * throws synchronously. `versionchange` is NOT involved here, so the cached + * handle would otherwise stay dead forever. + */ +function isConnectionClosingError(error) { + if (!error) return false; + if (error.name === 'InvalidStateError') return true; + const message = String(error.message || error); + return /connection is closing|database connection is closing/i.test(message); +} + +// Evict the dead connection `db` from the cache. Only touch it if it is +// STILL the cached handle: under concurrent recovery, another attempt may +// have already reopened and cached a fresh connection, and closing that +// fresh one would defeat the self-heal. When `db` is undefined (the open() +// itself failed, so nothing of ours is cached) this is a no-op. +function invalidateDedupeCache(dedupe, db) { + const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`; + const cached = dedupeDbCache.get(cacheKey); + if (cached && cached === db) { + try { cached.close(); } catch (_closeError) { /* already closing */ } + dedupeDbCache.delete(cacheKey); + } +} + +function invalidateQueueCache(db) { + if (cachedDB && cachedDB === db) { + try { cachedDB.close(); } catch (_closeError) { /* already closing */ } + cachedDB = null; + } +} + +/** + * Run `run(db)` against a cached IndexedDB connection, with a single + * transparent reopen if the connection turns out to be closing/closed. + * + * `db.transaction()` throws *synchronously* on a dead connection, and the + * `close` event (which would evict the cache) can land later than the next + * call — so we cannot rely on the `onclose` handler alone. On the first + * attempt we drop the cached handle and retry once; a second failure is + * surfaced as-is. The retry is capped at one to avoid spinning forever. + */ +async function withConnectionRetry(open, invalidate, run) { + for (let attempt = 0; attempt < 2; attempt++) { + let db; + try { + db = await open(); + } catch (error) { + // open() rejects only when nothing of ours is cached, so there is no + // specific handle to evict here. + if (attempt === 0) { invalidate(undefined); continue; } + throw error; + } + try { + return await run(db); + } catch (error) { + // Evict ONLY the handle that just failed — never whatever is cached + // now, which a concurrent attempt may have already reopened. + if (attempt === 0 && isConnectionClosingError(error)) { invalidate(db); continue; } + throw error; + } + } + // Unreachable: the loop returns or throws on attempt 1. + throw new Error('[rei-standard-amsg-sw] store connection retry exhausted'); +} + +function withDatabaseStore(storeName, mode, handler) { + return withConnectionRetry(openQueueDatabase, invalidateQueueCache, (db) => new Promise((resolve, reject) => { + let transaction; + try { + transaction = db.transaction(storeName, mode); + } catch (error) { + reject(error); + return; + } const store = transaction.objectStore(storeName); transaction.onerror = () => reject(transaction.error || new Error('Database transaction failed')); Promise.resolve(handler(store, resolve, reject)).catch(reject); - }); + })); } -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 withDedupeStore(dedupe, mode, handler) { + return withConnectionRetry( + () => openDedupeDatabase(dedupe), + (db) => invalidateDedupeCache(dedupe, db), + (db) => new Promise((resolve, reject) => { + let transaction; + try { + transaction = db.transaction(dedupe.storeName, mode); + } catch (error) { + reject(error); + return; + } + 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() { @@ -1394,10 +1580,19 @@ function openDedupeDatabase(dedupe) { request.onsuccess = () => { const db = request.result; dedupeDbCache.set(cacheKey, db); + // Only evict if WE are still the cached handle — a stale connection's + // late close event must not drop a freshly reopened one. + const drop = () => { + if (dedupeDbCache.get(cacheKey) === db) dedupeDbCache.delete(cacheKey); + }; db.onversionchange = () => { db.close(); - dedupeDbCache.delete(cacheKey); + drop(); }; + // Browser force-closed the connection (backing-store error / storage + // pressure / cleared data). This does NOT fire on versionchange, so + // without it the cache would keep handing out a dead connection. + db.onclose = () => { drop(); }; resolve(db); }; request.onerror = () => reject(request.error || new Error('Failed to open dedupe database')); @@ -1427,12 +1622,18 @@ function openQueueDatabase() { }; request.onsuccess = () => { - cachedDB = request.result; - cachedDB.onversionchange = () => { - cachedDB.close(); - cachedDB = null; + const db = request.result; + cachedDB = db; + db.onversionchange = () => { + db.close(); + if (cachedDB === db) cachedDB = null; + }; + // Browser force-closed the connection — evict so the next access + // reopens instead of reusing a dead handle. + db.onclose = () => { + if (cachedDB === db) cachedDB = null; }; - resolve(cachedDB); + resolve(db); }; request.onerror = () => reject(request.error || new Error('Failed to open queue database')); }); @@ -1443,17 +1644,22 @@ function createObjectStoreIfMissing(db, tx, name, options) { return db.createObjectStore(name, options); } -async function withQueueStore(mode, handler) { - const db = await openQueueDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(REI_SW_DB_STORE, mode); +function withQueueStore(mode, handler) { + return withConnectionRetry(openQueueDatabase, invalidateQueueCache, (db) => new Promise((resolve, reject) => { + let transaction; + try { + transaction = db.transaction(REI_SW_DB_STORE, mode); + } catch (error) { + reject(error); + return; + } const store = transaction.objectStore(REI_SW_DB_STORE); transaction.oncomplete = () => resolve(undefined); transaction.onerror = () => reject(transaction.error || new Error('Queue transaction failed')); Promise.resolve(handler(store, resolve, reject)).catch(reject); - }); + })); } async function addQueuedRequest(request) { diff --git a/packages/rei-standard-amsg/sw/test/connection-resilience.test.mjs b/packages/rei-standard-amsg/sw/test/connection-resilience.test.mjs new file mode 100644 index 0000000..50530d1 --- /dev/null +++ b/packages/rei-standard-amsg/sw/test/connection-resilience.test.mjs @@ -0,0 +1,240 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { installFakeIndexedDB } from './helpers/fake-indexeddb.mjs'; + +// Install a controllable IndexedDB BEFORE importing the SDK. `node --test` +// runs each test file in its own process, so this never leaks into the +// memory-fallback suite in dispatch.test.mjs. +const fake = installFakeIndexedDB(); + +const { installReiSW, REI_SW_EVENT } = await import('../src/index.js'); + +const QUEUE_DB_NAME = 'rei-sw'; + +let dbCounter = 0; +function uniqueDbName() { + dbCounter += 1; + return `dedupe_resilience_${dbCounter}`; +} + +function createSwMock() { + const listeners = new Map(); + const notifications = []; + const postedMessages = []; + const client = { + id: 'client-0', + visibilityState: 'hidden', + postMessage(message) { postedMessages.push(message); }, + }; + + const sw = { + addEventListener(name, handler) { listeners.set(name, handler); }, + registration: { + showNotification(title, options) { + notifications.push({ title, options: options || {} }); + return Promise.resolve(); + }, + }, + clients: { + async matchAll() { return [client]; }, + }, + }; + + async function triggerPush(payload) { + const pending = []; + listeners.get('push')({ + data: { json: () => payload }, + waitUntil(work) { pending.push(Promise.resolve(work)); }, + }); + await Promise.all(pending); + } + + return { sw, notifications, postedMessages, triggerPush }; +} + +function buildMultipartPayloads(payload, { id, maxChunkBytes = 80, ttlMs = 60_000, createdAt = Date.now() }) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + const total = Math.ceil(bytes.byteLength / maxChunkBytes); + return Array.from({ length: total }, (_, index) => { + const start = index * maxChunkBytes; + const chunk = bytes.subarray(start, Math.min(start + maxChunkBytes, bytes.byteLength)); + return { + messageKind: '_multipart', + multipart: { + version: 1, + id, + index: index + 1, + total, + encoding: 'json-utf8-base64url', + originalMessageKind: typeof payload.messageKind === 'string' ? payload.messageKind : null, + createdAt, + ttlMs, + }, + chunk: Buffer.from(chunk).toString('base64url'), + }; + }); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --- Smoke: the fake IndexedDB drives the real dedupe path end-to-end --- + +test('smoke: dedupe gate works over the fake IndexedDB (live connection)', async () => { + const business = []; + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName: uniqueDbName(), cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + const payload = { messageKind: 'content', message: 'hi', messageId: 'smoke_msg_1' }; + await triggerPush(payload); + await triggerPush(payload); + + assert.equal(business.length, 1, 'duplicate messageId dispatches business once'); + assert.equal(notifications.length, 1); +}); + +// --- Gap 1 (b): transaction-level reopen on a dead dedupe connection --- + +test('dedupe: a closed cached connection transparently reopens on the next push', async () => { + const dbName = uniqueDbName(); + const business = []; + const { sw, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName, cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + await triggerPush({ messageKind: 'content', message: 'a', messageId: 'reopen_a' }); + assert.equal(business.length, 1); + + // Simulate the connection going closed underneath the cache. close() + // does NOT fire `close`, so the cache still holds this dead handle and + // the next db.transaction() on it throws InvalidStateError. + fake.lastConnection(dbName).close(); + const opensBefore = fake.openCount; + + await triggerPush({ messageKind: 'content', message: 'b', messageId: 'reopen_b' }); + + assert.equal(business.length, 2, 'second push must still reach the business callback'); + assert.ok(fake.openCount > opensBefore, 'a fresh connection must have been opened'); +}); + +// --- Gap 1 (a): db.onclose evicts the cached dedupe connection --- + +test('dedupe: db.onclose evicts the cached connection so a strong-close self-heals', async () => { + const dbName = uniqueDbName(); + const business = []; + const { sw, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName, cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + await triggerPush({ messageKind: 'content', message: 'a', messageId: 'onclose_a' }); + const conn = fake.lastConnection(dbName); + + assert.equal(typeof conn.onclose, 'function', 'source must register db.onclose'); + + // Browser-style forced close event (the connection stays usable in the + // fake — this test verifies the cache eviction, not the transaction retry). + conn._emitClose(); + const opensBefore = fake.openCount; + + await triggerPush({ messageKind: 'content', message: 'b', messageId: 'onclose_b' }); + + assert.equal(business.length, 2); + assert.ok(fake.openCount > opensBefore, 'evicted cache forces a reopen on the next push'); +}); + +// --- Gap 1: cleanup self-heals instead of logging every sweep (criterion 3) --- + +test('dedupe cleanup: reopens after a dead connection instead of spamming "dedupe cleanup failed"', async () => { + const dbName = uniqueDbName(); + const business = []; + const { sw, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName, cleanupIntervalMs: 1 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + await triggerPush({ messageKind: 'content', message: 'a', messageId: 'cleanup_a' }); + + fake.lastConnection(dbName).close(); + await sleep(5); // let the cleanup interval elapse so the next push sweeps + + const originalError = console.error; + const errors = []; + console.error = (...args) => { errors.push(args.map(String).join(' ')); }; + try { + await triggerPush({ messageKind: 'content', message: 'b', messageId: 'cleanup_b' }).catch(() => {}); + } finally { + console.error = originalError; + } + + assert.ok( + !errors.some((line) => line.includes('dedupe cleanup failed')), + 'cleanup over a dead connection must reopen, not log a failure every sweep', + ); + assert.equal(business.length, 2, 'the push that triggered cleanup still lands'); +}); + +// --- Gap 1 (b): queue/multipart cachedDB reopens mid-reassembly (criterion 4) --- + +test('multipart: a closed queue connection reopens mid-reassembly', async () => { + const business = []; + const { sw, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName: uniqueDbName(), cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + const payload = { messageKind: 'content', message: 'queue reopen body '.repeat(10), messageId: 'queue_reopen_1' }; + const parts = buildMultipartPayloads(payload, { id: 'mp_queue_reopen', maxChunkBytes: 80 }); + assert.ok(parts.length >= 2, 'need a multi-chunk payload'); + + await triggerPush(parts[0]); + const deadConn = fake.lastConnection(QUEUE_DB_NAME); + deadConn.close(); + + for (const part of parts.slice(1)) { + await triggerPush(part).catch(() => {}); + } + + assert.notEqual( + fake.lastConnection(QUEUE_DB_NAME), + deadConn, + 'a new queue connection must have been opened after the close', + ); + assert.equal(business.length, 1, 'multipart payload restores and dispatches business'); +}); + +// --- Gap 1 (a): queue connection onclose evicts cachedDB (criterion 4) --- + +test('multipart: queue connection onclose evicts cachedDB so a strong-close self-heals', async () => { + const business = []; + const { sw, triggerPush } = createSwMock(); + installReiSW(sw, { + dedupe: { dbName: uniqueDbName(), cleanupIntervalMs: 0 }, + onBusinessPayload: (payload) => { business.push(payload); }, + }); + + const payload = { messageKind: 'content', message: 'queue onclose body '.repeat(10), messageId: 'queue_onclose_1' }; + const parts = buildMultipartPayloads(payload, { id: 'mp_queue_onclose', maxChunkBytes: 80 }); + + await triggerPush(parts[0]); + const conn = fake.lastConnection(QUEUE_DB_NAME); + + assert.equal(typeof conn.onclose, 'function', 'source must register onclose on the queue connection'); + + conn._emitClose(); + + for (const part of parts.slice(1)) { + await triggerPush(part); + } + + assert.notEqual(fake.lastConnection(QUEUE_DB_NAME), conn, 'evicted cachedDB forces a reopen'); + assert.equal(business.length, 1); +}); diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs index 36454a9..7db7c21 100644 --- a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -1058,3 +1058,490 @@ test('multipart fully received payload with notification.show: "when-hidden" che assert.equal(notifications[0].title, 'Multipart Hidden'); } }); + +// --- Gap 2 (method A): DELIVER ack reflects business landing failure --- + +// onBusinessPayload failures are logged by the SDK for observability. These +// tests intentionally trigger that, so capture console.error to keep the +// runner output clean while still asserting the log happened. +async function captureConsoleError(run) { + const originalError = console.error; + const errors = []; + console.error = (...args) => { errors.push(args.map(String).join(' ')); }; + try { + const value = await run(); + return { value, errors }; + } finally { + console.error = originalError; + } +} + +test('DELIVER ack surfaces businessError when onBusinessPayload rejects', async () => { + const { sw, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: () => Promise.reject(new Error('inbox write failed')), + }); + + const { value: replies, errors } = await captureConsoleError(() => triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-biz-reject', + payload: { ...COMMON, messageId: 'msg_biz_reject', messageKind: 'content', message: 'x' }, + })); + + // ok stays true (the SW DID receive and dispatch the payload); the + // optional businessError field carries the consumer's failure so callers + // that trust the ack can tell "delivered" from "actually persisted". + assert.deepEqual(replies[0], { + ok: true, + duplicate: false, + key: 'msg_biz_reject', + requestId: 'req-biz-reject', + businessError: 'inbox write failed', + }); + assert.ok( + errors.some((line) => line.includes('onBusinessPayload promise rejected')), + 'the rejection is still logged for observability', + ); +}); + +test('DELIVER ack surfaces businessError when onBusinessPayload throws synchronously', async () => { + const { sw, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: () => { throw new Error('sync boom'); }, + }); + + const { value: replies } = await captureConsoleError(() => triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-biz-throw', + payload: { ...COMMON, messageId: 'msg_biz_throw', messageKind: 'content', message: 'x' }, + })); + + assert.equal(replies[0].businessError, 'sync boom'); + assert.equal(replies[0].ok, true); +}); + +test('DELIVER ack omits businessError when onBusinessPayload resolves (back-compat shape)', async () => { + const { sw, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: () => Promise.resolve(), + }); + + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-biz-ok', + payload: { ...COMMON, messageId: 'msg_biz_ok', messageKind: 'content', message: 'x' }, + }); + + assert.deepEqual(replies[0], { + ok: true, + duplicate: false, + key: 'msg_biz_ok', + requestId: 'req-biz-ok', + }); + assert.ok(!('businessError' in replies[0]), 'success acks must not carry a businessError key'); +}); + +test('push: a rejecting onBusinessPayload does not reject the push delivery', async () => { + const { sw, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: () => Promise.reject(new Error('boom')), + }); + + // The webpush path has no ack to carry the error — it must stay silent + // and never let the business rejection escape the waitUntil chain. + await captureConsoleError(() => triggerPush( + { ...COMMON, messageId: 'msg_push_reject', messageKind: 'content', message: 'x' }, + )); + + assert.equal(postedMessages.length, 1, 'dispatch to clients still happened'); +}); + +test('DELIVER ack surfaces businessError on a later duplicate of a failed business delivery', async () => { + const { sw, triggerMessage } = createSwMock(); + let calls = 0; + installReiSW(sw, { + onBusinessPayload: () => { + calls += 1; + return Promise.reject(new Error('inbox write failed')); + }, + }); + + const payload = { ...COMMON, messageId: 'msg_dup_bizerr', messageKind: 'content', message: 'x' }; + + // First delivery: business fails, ack carries businessError (and the + // failure is persisted on the dedupe record). + const first = await captureConsoleError(() => triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-dup-1', + payload, + })); + assert.equal(first.value[0].businessError, 'inbox write failed'); + + // Retry the same key: deduped, business is NOT re-run, but the ack still + // honestly reports the unresolved business failure instead of a clean + // ok:true that would mislead a retry flow. + const second = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-dup-2', + payload, + }); + assert.deepEqual(second[0], { + ok: true, + duplicate: true, + key: 'msg_dup_bizerr', + requestId: 'req-dup-2', + businessError: 'inbox write failed', + }); + assert.equal(calls, 1, 'business must not re-run on the duplicate'); +}); + +test('DELIVER duplicate ack has no businessError when the first delivery succeeded', async () => { + const { sw, triggerMessage } = createSwMock(); + installReiSW(sw, { + onBusinessPayload: () => Promise.resolve(), + }); + + const payload = { ...COMMON, messageId: 'msg_dup_ok', messageKind: 'content', message: 'x' }; + await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-dupok-1', + payload, + }); + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-dupok-2', + payload, + }); + + assert.deepEqual(replies[0], { + ok: true, + duplicate: true, + key: 'msg_dup_ok', + requestId: 'req-dupok-2', + }); + assert.ok(!('businessError' in replies[0]), 'a clean success must not leave a sticky businessError'); +}); + +test('a failed first-delivery business callback must not clobber a notification a backup already repaired', async () => { + // Race: first delivery is visible (no notification) with a slow business + // callback; a hidden backup repairs the notification (notificationShown -> + // true) while business is still pending; then business fails. Persisting + // the error must merge onto the LATEST record, not overwrite from the + // first delivery's stale snapshot — otherwise notificationShown flips back + // to false and the next duplicate shows a second notification. + let rejectBusiness; + const businessGate = new Promise((_resolve, reject) => { rejectBusiness = reject; }); + + const { sw, listeners, notifications, triggerPush, setVisibleCount } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + onBusinessPayload: () => businessGate, + }); + + const payload = { + ...COMMON, + messageId: 'msg_clobber_repair', + messageKind: 'content', + message: 'x', + notification: { show: 'when-hidden', title: 'Repaired once' }, + }; + + // First delivery (SSE): visible client suppresses the notification, business + // is gated and stays pending. + const messageHandler = listeners.get('message'); + const ssePending = []; + messageHandler({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-clobber-1', payload }, + ports: [{ postMessage() {} }], + waitUntil(work) { ssePending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(notifications.length, 0, 'visible first delivery shows no notification'); + + // Backup arrives while business is pending; client now hidden -> repairs. + setVisibleCount(0); + await triggerPush(payload); + assert.equal(notifications.length, 1, 'backup repaired the notification once'); + + // First delivery's business now FAILS. Persisting the error must not undo + // the repair above. + rejectBusiness(new Error('inbox write failed')); + await captureConsoleError(() => Promise.all(ssePending)); + + // A third copy of the same key must NOT trigger another notification. + await triggerPush(payload); + assert.equal(notifications.length, 1, 'no second notification after the failed business write'); +}); + +test('a failed first delivery must not stamp its error onto a TTL-renewed claim', async () => { + // Race: the first delivery's business callback outlives the dedupe TTL, so + // the same key is later accepted as a brand-new claim whose business + // SUCCEEDS. When the long-dead first delivery finally fails, its error must + // not be written onto the renewed (successful) record, or every later + // duplicate would falsely report the stale failure. + let rejectFirst; + const firstGate = new Promise((_resolve, reject) => { rejectFirst = reject; }); + let calls = 0; + const { sw, listeners, triggerMessage } = createSwMock(); + installReiSW(sw, { + dedupe: { ttlMs: 30, cleanupIntervalMs: 0, dbName: 'rei_amsg_sw_dedupe_ttl_renew' }, + onBusinessPayload: () => { + calls += 1; + return calls === 1 ? firstGate : Promise.resolve(); + }, + }); + + const payload = { ...COMMON, messageId: 'msg_ttl_renew', messageKind: 'content', message: 'x' }; + + // First delivery: business is gated and stays pending past the TTL. + const firstPending = []; + listeners.get('message')({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-r1', payload }, + ports: [{ postMessage() {} }], + waitUntil(work) { firstPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls, 1, 'first delivery entered the business callback'); + + // TTL elapses so the same key is accepted as a fresh claim. + await new Promise((resolve) => setTimeout(resolve, 45)); + + // Renewed delivery: re-claims the key and its business SUCCEEDS. + await triggerMessage({ type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-r2', payload }); + assert.equal(calls, 2, 'renewed delivery ran a second business callback'); + + // The long-dead first delivery now fails — its error must not contaminate + // the renewed claim. + rejectFirst(new Error('stale failure')); + await captureConsoleError(() => Promise.all(firstPending)); + + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-r3', + payload, + }); + assert.equal(replies[0].duplicate, true); + assert.ok( + !('businessError' in replies[0]), + 'stale first-delivery error must not attach to the renewed claim', + ); +}); + +test('a concurrent duplicate notification repair must not erase a persisted businessError', async () => { + // Race: a backup reads the dedupe record and parks inside showNotification. + // While it is parked, the first delivery's business fails and persists + // businessError. When the backup resumes it writes its repair record back — + // from a pre-await snapshot — which must NOT clobber the businessError. + let rejectBusiness; + const businessGate = new Promise((_resolve, reject) => { rejectBusiness = reject; }); + + const { sw, listeners, setVisibleCount, triggerMessage } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + onBusinessPayload: () => businessGate, + }); + + const payload = { + ...COMMON, + messageId: 'msg_repair_erase', + messageKind: 'content', + message: 'x', + notification: { show: 'when-hidden', title: 'Repair' }, + }; + + // First delivery (SSE), visible: no notification, but the notification + // state settles (notificationStatePending -> false) while business stays + // pending behind the gate. + const firstPending = []; + listeners.get('message')({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-e1', payload }, + ports: [{ postMessage() {} }], + waitUntil(work) { firstPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + // Park the backup inside showNotification. + let releaseShow; + const showGate = new Promise((resolve) => { releaseShow = resolve; }); + sw.registration.showNotification = () => showGate; + + // Backup (WebPush), now hidden: duplicate that enters the repair path and + // parks awaiting showNotification. + setVisibleCount(0); + const backupPending = []; + listeners.get('push')({ + data: { json: () => payload }, + waitUntil(work) { backupPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + // First delivery's business now fails -> businessError persisted onto the + // record while the backup is still parked in showNotification. + rejectBusiness(new Error('inbox write failed')); + await captureConsoleError(() => Promise.all(firstPending)); + + // Backup resumes and writes its repair record back. + releaseShow(); + await Promise.all(backupPending); + + // A later duplicate must still surface the businessError. + const replies = await triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-e2', + payload, + }); + assert.equal( + replies[0].businessError, + 'inbox write failed', + 'the duplicate notification repair must not erase the persisted businessError', + ); +}); + +test('an in-flight duplicate DELIVER ack must reflect a businessError persisted during its repair', async () => { + // Read-side race: a duplicate reads `claim.existing` at claim time (no + // businessError yet), then parks inside showNotification. While parked, the + // first delivery's business fails and persists businessError. When the + // duplicate resumes it must ack from the LATEST record, not its stale + // snapshot, or it falsely reports a clean ok:true. + let rejectBusiness; + const businessGate = new Promise((_resolve, reject) => { rejectBusiness = reject; }); + + const { sw, listeners, setVisibleCount } = createSwMock({ clientCount: 1, visibleCount: 1 }); + installReiSW(sw, { + onBusinessPayload: () => businessGate, + }); + + const payload = { + ...COMMON, + messageId: 'msg_dup_ack_race', + messageKind: 'content', + message: 'x', + notification: { show: 'when-hidden', title: 'Repair' }, + }; + + // First delivery (SSE), visible: no notification, business pending; the + // notification state settles so the duplicate can enter the repair path. + const firstPending = []; + listeners.get('message')({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-f1', payload }, + ports: [{ postMessage() {} }], + waitUntil(work) { firstPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + // Park the duplicate inside showNotification. + let releaseShow; + const showGate = new Promise((resolve) => { releaseShow = resolve; }); + sw.registration.showNotification = () => showGate; + + // Backup DELIVER (hidden): duplicate that enters the repair path and parks + // awaiting showNotification. Capture its ack. + setVisibleCount(0); + const backupReplies = []; + const backupPending = []; + listeners.get('message')({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-dup', payload }, + ports: [{ postMessage(reply) { backupReplies.push(reply); } }], + waitUntil(work) { backupPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + // First delivery's business now fails -> businessError persisted while the + // duplicate is still parked. + rejectBusiness(new Error('inbox write failed')); + await captureConsoleError(() => Promise.all(firstPending)); + + // Duplicate resumes and acks. + releaseShow(); + await Promise.all(backupPending); + + assert.equal(backupReplies[0].duplicate, true); + assert.equal( + backupReplies[0].businessError, + 'inbox write failed', + 'in-flight duplicate ack must reflect the businessError persisted during its repair', + ); +}); + +test('an in-flight duplicate must not inherit a businessError from a TTL-renewed claim', async () => { + // Read-side symmetric to the write-side TTL guard: a duplicate is delayed + // past the dedupe TTL inside showNotification; meanwhile the same key is + // reclaimed as a fresh delivery whose business FAILS. The stale duplicate + // must not report that newer (unrelated) claim's failure on its ack. + let calls = 0; + const { sw, listeners, setVisibleCount, triggerMessage } = createSwMock({ + clientCount: 1, + visibleCount: 1, + }); + installReiSW(sw, { + dedupe: { ttlMs: 30, cleanupIntervalMs: 0, dbName: 'rei_amsg_sw_dedupe_dup_renew' }, + onBusinessPayload: () => { + calls += 1; + return calls === 1 ? Promise.resolve() : Promise.reject(new Error('renewed failure')); + }, + }); + + const payload = { + ...COMMON, + messageId: 'msg_dup_renew_ack', + messageKind: 'content', + message: 'x', + notification: { show: 'when-hidden', title: 'Repair' }, + }; + + // A: first delivery, visible -> no notification, business succeeds. Record T0. + await triggerMessage({ type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-a', payload }); + assert.equal(calls, 1); + + // Park the duplicate (B) inside showNotification. + let releaseShow; + const showGate = new Promise((resolve) => { releaseShow = resolve; }); + sw.registration.showNotification = () => showGate; + + // B: duplicate, hidden -> enters repair path, parks awaiting showNotification. + setVisibleCount(0); + const bReplies = []; + const bPending = []; + listeners.get('message')({ + data: { type: REI_AMSG_DELIVER_MESSAGE_TYPE, source: 'sse', requestId: 'req-b', payload }, + ports: [{ postMessage(reply) { bReplies.push(reply); } }], + waitUntil(work) { bPending.push(Promise.resolve(work)); }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + // TTL elapses so the key can be reclaimed by a new delivery. + await new Promise((resolve) => setTimeout(resolve, 45)); + + // C: renewed delivery (show:false so it never parks), business FAILS and + // persists businessError onto the NEW record. + await captureConsoleError(() => triggerMessage({ + type: REI_AMSG_DELIVER_MESSAGE_TYPE, + source: 'sse', + requestId: 'req-c', + payload: { ...payload, notification: { show: false } }, + })); + assert.equal(calls, 2); + + // Release B; its ack must NOT inherit C's (the renewed claim's) failure. + releaseShow(); + await Promise.all(bPending); + + assert.equal(bReplies[0].duplicate, true); + assert.ok( + !('businessError' in bReplies[0]), + 'duplicate ack must not inherit a businessError from a TTL-renewed claim', + ); +}); diff --git a/packages/rei-standard-amsg/sw/test/helpers/fake-indexeddb.mjs b/packages/rei-standard-amsg/sw/test/helpers/fake-indexeddb.mjs new file mode 100644 index 0000000..052dfe5 --- /dev/null +++ b/packages/rei-standard-amsg/sw/test/helpers/fake-indexeddb.mjs @@ -0,0 +1,410 @@ +/** + * Minimal, *controllable* in-memory IndexedDB fake for resilience tests. + * + * It implements only the slice of the IndexedDB surface that + * `src/index.js` actually touches: + * - indexedDB.open() with onupgradeneeded / onsuccess / onerror + * - db.transaction() that THROWS InvalidStateError once the connection + * is closed (via db.close()) or force-killed (db._forceDead()) + * - db.onversionchange / db.onclose hooks + * - object stores: add / get / put / delete / count / getAll / + * getAllKeys / openCursor + a single `expiresAt` index + * - IDBKeyRange.upperBound() + * + * Why hand-rolled instead of `fake-indexeddb`: this package ships with + * zero runtime deps and the tests must be able to *deliberately* put a + * cached connection into the "closing" state to reproduce the strong-close + * bug. A real polyfill never lets you do that. + * + * Async semantics mirror real IDB: request callbacks fire on a later + * microtask, so the source's `request.onsuccess = ...` assignment (which + * happens *after* the call) is always in place before the callback runs. + */ + +class FakeDOMException extends Error { + constructor(message, name) { + super(message); + this.name = name; + } +} + +function fire(handler, arg) { + if (typeof handler === 'function') handler(arg); +} + +function schedule(fn) { + queueMicrotask(fn); +} + +function valueAtPath(record, keyPath) { + if (record == null) return undefined; + return record[keyPath]; +} + +function inRange(value, range) { + if (!range) return true; + if (range._type === 'upperBound') { + return range.upperOpen ? value < range.upper : value <= range.upper; + } + return true; +} + +export const FakeIDBKeyRange = { + upperBound(upper, upperOpen = false) { + return { _type: 'upperBound', upper, upperOpen }; + }, +}; + +class FakeRequest { + constructor() { + this.onsuccess = null; + this.onerror = null; + this.result = undefined; + this.error = null; + } +} + +class FakeIndex { + constructor(store, def) { + this._store = store; + this._keyPath = def.keyPath; + } + + _matching(range) { + const out = []; + for (const [primaryKey, value] of this._store._data.records.entries()) { + if (inRange(valueAtPath(value, this._keyPath), range)) { + out.push({ primaryKey, value }); + } + } + return out; + } + + getAll(range) { + const req = new FakeRequest(); + const rows = this._matching(range); + schedule(() => { + req.result = rows.map((r) => r.value); + fire(req.onsuccess); + }); + return req; + } + + getAllKeys(range) { + const req = new FakeRequest(); + const rows = this._matching(range); + schedule(() => { + req.result = rows.map((r) => r.primaryKey); + fire(req.onsuccess); + }); + return req; + } + + openCursor(range) { + const req = new FakeRequest(); + const store = this._store; + const keys = this._matching(range).map((r) => r.primaryKey); + let i = 0; + + const step = () => { + schedule(() => { + if (i >= keys.length) { + req.result = null; + fire(req.onsuccess); + return; + } + const primaryKey = keys[i]; + const cursor = { + delete() { + const dreq = new FakeRequest(); + schedule(() => { + store._data.records.delete(primaryKey); + dreq.result = undefined; + fire(dreq.onsuccess); + }); + return dreq; + }, + continue() { + i += 1; + step(); + }, + }; + req.result = cursor; + fire(req.onsuccess); + }); + }; + + step(); + return req; + } +} + +class FakeObjectStore { + constructor(transaction, data) { + this.transaction = transaction; + this._data = data; + this.keyPath = data.keyPath; + this.indexNames = { + contains: (name) => data.indexes.has(name), + }; + } + + _key(record) { + let key = valueAtPath(record, this._data.keyPath); + if (key === undefined && this._data.autoIncrement) { + this._data.autoIncrementCounter += 1; + key = this._data.autoIncrementCounter; + record[this._data.keyPath] = key; + } + return key; + } + + add(record) { + const req = new FakeRequest(); + const key = this._key(record); + schedule(() => { + if (this._data.records.has(key)) { + req.error = new FakeDOMException('Key already exists', 'ConstraintError'); + const event = { + _prevented: false, + preventDefault() { this._prevented = true; }, + }; + fire(req.onerror, event); + if (!event._prevented) this.transaction._fail(req.error); + return; + } + this._data.records.set(key, record); + req.result = key; + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + put(record) { + const req = new FakeRequest(); + const key = this._key(record); + schedule(() => { + this._data.records.set(key, record); + req.result = key; + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + get(key) { + const req = new FakeRequest(); + schedule(() => { + req.result = this._data.records.has(key) ? this._data.records.get(key) : undefined; + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + delete(key) { + const req = new FakeRequest(); + schedule(() => { + this._data.records.delete(key); + req.result = undefined; + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + count(key) { + const req = new FakeRequest(); + schedule(() => { + req.result = key === undefined + ? this._data.records.size + : (this._data.records.has(key) ? 1 : 0); + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + getAll() { + const req = new FakeRequest(); + schedule(() => { + req.result = Array.from(this._data.records.values()); + fire(req.onsuccess); + this.transaction._settleSimple(); + }); + return req; + } + + createIndex(name, keyPath, options = {}) { + this._data.indexes.set(name, { keyPath, unique: Boolean(options.unique) }); + return new FakeIndex(this, this._data.indexes.get(name)); + } + + index(name) { + const def = this._data.indexes.get(name); + if (!def) throw new FakeDOMException(`No index ${name}`, 'NotFoundError'); + return new FakeIndex(this, def); + } +} + +class FakeTransaction { + constructor(db, storeNames, mode) { + this.db = db; + this.mode = mode; + this.error = null; + this.onerror = null; + this.oncomplete = null; + this.onabort = null; + this._storeNames = Array.isArray(storeNames) ? storeNames : [storeNames]; + this._completed = false; + this._failed = false; + } + + objectStore(name) { + const data = this.db._meta.stores.get(name); + if (!data) throw new FakeDOMException(`No object store ${name}`, 'NotFoundError'); + return new FakeObjectStore(this, data); + } + + // Fire oncomplete once the simple (non-cursor) request that drove this + // transaction has resolved. Good enough for the single-op transactions + // the queue store relies on (`withQueueStore`). + _settleSimple() { + if (this._completed || this._failed) return; + this._completed = true; + schedule(() => fire(this.oncomplete)); + } + + _fail(error) { + if (this._failed || this._completed) return; + this._failed = true; + this.error = error; + schedule(() => fire(this.onerror)); + } + + abort() { + this._fail(new FakeDOMException('aborted', 'AbortError')); + } +} + +class FakeDatabase { + constructor(fake, meta) { + this._fake = fake; + this._meta = meta; + this._closed = false; + this._dead = false; + this.onversionchange = null; + this.onclose = null; + this.objectStoreNames = { + contains: (name) => this._meta.stores.has(name), + }; + } + + transaction(storeNames, mode = 'readonly') { + if (this._closed || this._dead) { + throw new FakeDOMException( + "Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", + 'InvalidStateError', + ); + } + return new FakeTransaction(this, storeNames, mode); + } + + createObjectStore(name, options = {}) { + const data = { + name, + keyPath: options.keyPath, + autoIncrement: Boolean(options.autoIncrement), + autoIncrementCounter: 0, + records: new Map(), + indexes: new Map(), + }; + this._meta.stores.set(name, data); + const upgradeTx = new FakeTransaction(this, [name], 'versionchange'); + return new FakeObjectStore(upgradeTx, data); + } + + close() { + this._closed = true; + } + + /** Test hook: simulate the browser force-closing this connection. */ + _forceDead() { + this._dead = true; + } + + /** Test hook: dispatch the `close` event the way the browser would. */ + _emitClose() { + fire(this.onclose); + } +} + +export class FakeIndexedDB { + constructor() { + this._databases = new Map(); // name -> { version, stores: Map } + this._connections = new Map(); // name -> last opened FakeDatabase + this.openCount = 0; + } + + open(name, version = 1) { + this.openCount += 1; + const req = new FakeRequest(); + req.transaction = null; + + let meta = this._databases.get(name); + const isNew = !meta; + if (isNew) { + meta = { version: 0, stores: new Map() }; + this._databases.set(name, meta); + } + const needsUpgrade = version > meta.version; + + const db = new FakeDatabase(this, meta); + req.result = db; + + schedule(() => { + if (needsUpgrade) { + const upgradeTx = new FakeTransaction(db, [], 'versionchange'); + upgradeTx.objectStore = (storeName) => { + const data = meta.stores.get(storeName); + if (!data) throw new FakeDOMException(`No object store ${storeName}`, 'NotFoundError'); + return new FakeObjectStore(upgradeTx, data); + }; + req.transaction = upgradeTx; + meta.version = version; + fire(req.onupgradeneeded, { oldVersion: 0, newVersion: version }); + req.transaction = null; + } + this._connections.set(name, db); + fire(req.onsuccess); + }); + + return req; + } + + /** Most recently opened (and therefore cached) connection for a db name. */ + lastConnection(name) { + return this._connections.get(name); + } + + deleteDatabase(name) { + const req = new FakeRequest(); + this._databases.delete(name); + this._connections.delete(name); + schedule(() => fire(req.onsuccess)); + return req; + } +} + +/** + * Install a fresh fake IndexedDB onto globalThis and return it. Call once + * at module scope of a test file; `node --test` isolates each file in its + * own process so this never leaks into the memory-fallback suites. + */ +export function installFakeIndexedDB() { + const fake = new FakeIndexedDB(); + globalThis.indexedDB = fake; + globalThis.IDBKeyRange = FakeIDBKeyRange; + return fake; +} From 7ae5653bd3dfcdb04d2bd3668196bf1a8a534bb0 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:15:43 +0800 Subject: [PATCH 2/7] feat(amsg-client): add deliver() delivery primitive (2.5.0-next.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 主要变化 新增 `client.deliver(payload, opts)` 作为推荐入口,统一协调 SSE / JSON 两路 transport 与 caller 提供的「观察通道」Promise, 返回 5 值 outcome(delivered / cancelled / timeout / send-failed / completed-unconfirmed),让接入方一眼就能正确分辨「真送达」与 「transport 失败」。 平台无关:库不绑 Service Worker / IndexedDB / Web Push / 任何 具体后端;observation 通道由 caller 注入一个普通 Promise,谁能 拿到 receipt 都能接(SW broadcast、Electron IPC、原生桥、轮询 fallback……)。 # 为什么这样设计 amsg-instant 0.9.0+ 强制 backupPush='on' 后,SSE + Web Push 双通道并发。旧 API 的两条单一信号路径都变得不可靠: sendInstant() 200 ≠ 真送达;consumeInstantStream() reject ≠ 真发送失败。naive 接入方 try/catch reject 会持续误报失败。 deliver() 把 transport 降为辅助快通道,把 receipt 作为唯一真相, 彻底消除这条陷阱。 # 旧 API 软降级 - sendInstant / consumeInstantStream 仍可用,wire-level 字节不变 - JSDoc 改标 "Low-level",指向 deliver() - 新增可选 opts.expectsBackupPush 触发一次性 dev warning - 留两 minor 缓冲到 3.0.0,不立刻 @deprecated # 多 agent self-review + Codex 独立 review 9-angle finder(line-scan / removed-behavior / cross-file / JS pitfalls / wrapper / reuse / simplification / efficiency / altitude)抓 15 条 + Codex 二次抓 7 条,全部修:CRLF SSE 帧分隔 (含跨 chunk seam)、TextDecoder EOF flush、本地校验错误不再被 埋成 send-failed、abort 全路径 honor(pre-flight + 异步 build 后 + post-transport grace)、AbortSignal listener 卸载、 transport-only 短路 grace、application/+json 结构化后缀识别、 Content-Type 改 media-type 解析、Promise reaction 累积消除。 发布在 `next` dist-tag 给 SullyOS 端到端验证(iOS PWA + SW 双通道 + 后台杀 fetch 等 Node 单测无法仿真的场景);55 条单测全 绿后再 graduate 到 latest 作为 2.5.0 正式版。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/client/CHANGELOG.md | 76 ++ packages/rei-standard-amsg/client/README.md | 534 +++++--- .../rei-standard-amsg/client/package.json | 2 +- .../rei-standard-amsg/client/src/index.js | 951 ++++++++++++--- .../client/test/deliver.test.mjs | 1070 +++++++++++++++++ 5 files changed, 2316 insertions(+), 317 deletions(-) create mode 100644 packages/rei-standard-amsg/client/test/deliver.test.mjs diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index 0a91728..dd1fb91 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog — @rei-standard/amsg-client +## 2.5.0-next.0 — `deliver()` 平台无关送达 primitive (pre-release) + +发布在 `next` dist-tag。供 SullyOS 等真实接入方端到端验证 deliver() 在 iOS PWA / SW 双通道实战下的行为;本地 55 条单测全过但 SSE 流式 / Web Push backup / iOS 后台杀 fetch 等场景没法在 Node 里仿真,所以先 next 再升 latest。验收 OK 后 graduate 到 `2.5.0`(`npm dist-tag add` 或重发正式版)。 + +把"发出去"和"业务上是否真送达"在 API 层显式分开。新增 `client.deliver()` 作为新代码的首选入口;老的 `sendInstant()` / `consumeInstantStream()` 仍可用但降级为低级 transport,配 opt-in dev warning 引导迁移。SSE 与 JSON 两条 transport 一并升级到统一的送达协调层,调用方无需感知。 + +### New + +- 新增 `client.deliver(payload, opts)`:单一入口,根据响应 `Content-Type` 自动选 SSE 或 JSON transport,与 caller 提供的「观察通道 `Promise`」做 race + grace,返回 `DeliveryResult` 含五值 `outcome`(`delivered` / `cancelled` / `timeout` / `send-failed` / `completed-unconfirmed`)。 +- 观察通道是 **平台无关 Promise**:库不绑 Service Worker / IndexedDB / Web Push / 任何具体后端,调用方自己把 SW 广播、IPC、原生桥、轮询、自定义通道包成 Promise 即可。 +- `delivery` 用 discriminated union 显式声明 `mode: 'observed' | 'transport-only'`,不允许「传永不 resolve 的 Promise 假装在 observed 模式」的写法。 +- `outcome:'delivered'` 仅 observed 模式可达,且必须 receipt identity 校验通过(receipt 至少含 `messageId` 或 `sessionId` 之一的非空字符串);invalid receipt 视为「观察从未触发」继续 race,杜绝并发串单。 +- `outcome:'cancelled'` 独立于 `timeout` / `send-failed`:caller `signal.abort()` 触发;但若 grace 内仍观察到 receipt,会改报 `delivered` + `detail.cancelledByCaller: true`(iOS 切回前台后 push 仍接力的实战场景)。 +- `outcome:'timeout'` 在 observed 模式 + transport 干净结束 + observation 未接力 时,额外带 `detail.observationChannelStalled: true`——观察通道挂了不等于发送失败。 +- `outcome:'send-failed'` 仅在 transport 有 captured error **且** 观察通道也没接力时触发。 +- `outcome:'completed-unconfirmed'` 仅 transport-only 模式专用,明确标注「best-effort 乐观,无真相信号」。 +- Pre-flight `signal.aborted` 检查:进入时若已 aborted,直接返回 `cancelled`,不下发 fetch。 +- `postTransportGraceMs` 默认 = `min(remainingBudget, max(5000, timeoutMs * 0.1))`:5s 下限 + 10% 比例,跨 30s / 300s / 多分钟 timeout 都有合理 grace。 +- `onChunk`(可选 SSE 每帧 UI 钩子)抛错被捕获进 `detail.chunkHandlerError`,**不**升级 outcome 到 `send-failed`——UI 钩子失败是 caller-bug-shaped。 + +### Soft-deprecated(仍可用,文档与 warning 引导迁移) + +- `sendInstant()` JSDoc 改标 **Low-level JSON dispatcher**,提示 「HTTP 200 ≠ delivery confirmation」当 backup push 开启时。 +- `consumeInstantStream()` JSDoc 改标 **Low-level SSE consumer**,提示 「rejection ≠ delivery failure」当 backup push 开启时。 +- 两者新增可选 `opts.expectsBackupPush`: + - `true` → 实例 + 方法首次调用时 `console.warn` 一次(migration 审计用) + - `false` → 显式表示「我知道这点」永久静音 + - 不传 → 不警告 +- 没有立刻 `@deprecated`,留两个 minor 缓冲到 3.0.0。 + +### 内部重构(行为字节不变) + +- 抽取私有 `_buildInstantRequest` / `_runInstantTransport` / `_consumeSseStream`,`sendInstant` / `consumeInstantStream` / `deliver` 三条路径共用。 +- SSE 解析逻辑与 2.4.0 byte-identical(多行 `data:` 用 `\n` 拼接、`event: done` 优先、EOF 视为 done、`event: error` 解 JSON 抛带 `code` 的 Error)。 + +### Migration + +| 旧写法 | 新写法 | +| --- | --- | +| `try { await consumeInstantStream(p, '/instant', { onPayload }) } catch { fail() }` | `const r = await deliver(p, { delivery: { mode: 'observed', observed }, timeoutMs, onChunk: onPayload }); if (r.outcome !== 'delivered') ...` | +| `const r = await sendInstant(p); if (!r.success) fail()` | `const r = await deliver(p, { delivery: { mode: 'observed', observed }, timeoutMs }); if (r.outcome === 'send-failed') ...` | +| `sendInstant(p, '/instant', { authorization: 'Bearer ...' })` | `deliver(p, { delivery, timeoutMs, authorization: 'Bearer ...' })` | + +详见 README 的 `deliver()` 标准用法与「为什么需要 `deliver()`」段。 + +### 发布前 review 期修复(折叠进 2.5.0) + +Self-review 时(仿 ultrareview 多角度分派)抓到的 correctness 修复,均不破前面任何 API: + +- **SSE 帧分隔**:原 `buffer.split('\n\n')` 在 CRLF 服务端(.NET / IIS / 某些 CDN)下永远拼不到分隔符,全流静默丢。改成先 `\r\n?` → `\n` 整 buffer 归一化再 split,覆盖 `\r\n\r\n` / `\n\n` / `\r\r` 与跨 chunk seam 的混合行尾。 +- **SSE EOF flush**:流结束时漏 `decoder.decode()` 收尾 + 漏处理无尾随空行的最后一帧。两处都补上,避免跨 chunk 的 UTF-8 多字节字符丢字节、最后一帧静默丢。 +- **本地校验错误不再被埋**:`PAYLOAD_TOO_LARGE_LOCAL` / 加密未初始化等本地错误现在直接从 `deliver()` 抛出,不再被吞进 IIFE 变成 `outcome:'send-failed'` + `detail.transportError`。请求构造提前到 race 启动之前。 +- **post-return 写穿防护**:observed 模式赢 race 后,仍在跑的 transport IIFE 不再有机会改 caller 已持有的 `detail`(`finalized` 闸口同步关)。 +- **caller signal listener 卸载**:每个终态都会 removeEventListener,长生命周期 `AbortController` 跨 N 次调用不再累积 2N 个 stale 闭包。 +- **abort 微任务窗口竞态**:pre-flight 与 listener 注册之间窗口内 abort 触发时,新注册的 listener 不会 fire(DOM spec),现在 addEventListener 后会再查一次 `signal.aborted` 并补触发。 +- **transport-only + cancel 不再 linger**:`mode: 'transport-only'` 下 caller abort 之后直接返回,不再死等 grace/2 拿一个永远不会到的 observation。 +- **`deliver()` 接受 `opts.authorization`**:从 `sendInstant({authorization})` 迁过来时不会再静默丢 header。 +- **结构化 JSON Content-Type**:`application/problem+json` / `application/vnd.api+json` 这类 structured-suffix variant 现在被识别为 JSON。 +- **JSDoc 写明 cancel grace `/2`**:`postTransportGraceMs` 注释明确 cancel 路径生效的是 `grace/2`(一半留给清理)。 + +依赖与外部接口零变更;以上全部在 `client` 包内部完成,并加了 9 条 regression 测试覆盖。 + +### Codex review 后追加的修复(同样折叠进 2.5.0) + +走完一轮 9-angle self-review 之后,又请 Codex 独立读了一遍 working tree,抓到 7 个我漏的: + +- **transport-only 模式 transport 结束后仍然等 grace**:之前只 fix 了 cancel 路径,post-transport-ended 路径还在白等(`timeoutMs: 60_000` 默认会多卡 ~5s)。observed mode 才有观察通道值得等,transport-only 直接按 transport 结果出 outcome。 +- **abort 期间 `_buildInstantRequest` 仍可能发 fetch**:pre-flight 只查了一次,但 build 是 async(加密走 Web Crypto 会 await),signal 在 build 中途 abort 会被吞,仍走 fetch。现在 build 完成后再查一次 `signal.aborted`,aborted 就直接返回 cancelled 不下发请求。 +- **post-transport grace 期间 abort 被忽略**:transport 先结束后,late-receipt 等待只 race `validatedObserved` + 自己的 timer,没 race `cancelledP`。caller 在 grace 期间 abort 会被错报成 timeout / send-failed。现在 grace 等待跟 cancel signal 一起 race,abort 赢就报 cancelled。 +- **SSE CRLF 跨 chunk seam 仍然破**:第一轮修了 `\r\n\r\n` 的统一归一化,但当真实 CRLF 正好被分到两个 chunk(chunk1 末尾 `\r`、chunk2 开头 `\n`),原 normalize 会把 chunk1 的 trailing `\r` 提前变成 `\n`,再跟下一个 chunk 拼成 `\n\n` 误判帧边界。修:把 trailing `\r` 留到下一 chunk 再统一归一化。 +- **`onChunk` 抛错跨 deliver-return mutate detail**:上轮防了 transport IIFE 的 `detail.transportResponse` 写穿,但 `wrappedOnChunk` 的 catch 仍直接写 `detail.chunkHandlerError`,observed 赢 race 返回后 onChunk 延迟 throw 仍能改 caller 持有的 detail。现在 `chunkHandlerError` 写入也 gate 在 `finalized`。 +- **Content-Type 用 substring 不是 media-type 解析**:`application/json; note=text/event-stream` 这种参数里带其他媒体类型的会被错认。改成严格 media-type 解析:先用 `;` 切参数、trim、lowercase,再 exact match + structured-suffix 正则。`consumeInstantStream` 的 SSE 检查也一并改成走 `classifyContentType`。 +- **`NEVER_SETTLES` 共享 sentinel 累积 Promise reactions**:`Promise.race` 每次都给那个全局永不 settle 的 Promise 挂 reaction,长生命周期页面会持续累积。改成条件式构造 race 数组——transport-only 不参 observed/`validatedObserved`,无 signal 不参 cancelledP,整个 `NEVER_SETTLES` 常量直接删掉。 + +测试集相应扩到 55 条,覆盖以上每个修复 + transport-only 短路 + 跨 chunk seam 的真 CRLF 场景;之前自己写的 5 条直接动 `globalThis.fetch` 的测试也改成走 `installFetch()` restore 模式,避免污染更大 suite。 + ## 2.4.0 — `consumeInstantStream()` SSE consumer 配套 `@rei-standard/amsg-instant@0.9.0` 的 SSE 默认模式;同时移除 client 默认请求体大小上限,避免本地误拦长上下文请求。 diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index ec256ef..7595c6a 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -1,47 +1,33 @@ # @rei-standard/amsg-client -`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。 +`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应、Push 订阅,以及 **送达协调**。 -## v2.4.0 — SSE consumer +## v2.5.0 — `deliver()`:平台无关的送达 primitive -新增 `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`。 +新增 `client.deliver(payload, opts)`,把"发出去"和"业务上是否真送达"分离开。它是新代码的**首选入口**。 -## v2.3.0 — Shared push types +旧的 `sendInstant()` / `consumeInstantStream()` 仍然保留可用,但被降级为**低级 transport**——只在你已经自己接好了送达校验时才用,否则会出现「HTTP 200 / SSE 不报错 ≠ 消息真送到」的陷阱(详见下方[为什么需要 `deliver()`](#为什么需要-deliver))。 -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 用的便利出口。 +> 与 `@rei-standard/amsg-instant` 0.9.0+ 配合最直接,但 `deliver()` 本身**不绑死任何后端 / 平台**——它接收一个普通的 `Promise` 作为"观察通道"信号源,**谁都能接**(Service Worker 广播、Electron IPC、原生桥、轮询、自定义 long-poll……)。 -```js -// app.js — 用 ReiClient 发即时消息 -import { ReiClient } from '@rei-standard/amsg-client'; +--- -const client = new ReiClient({ - baseUrl: 'https://instant.example.com', - instantEncryption: false, -}); -await client.sendInstant({ - contactName: 'Rei', - completePrompt: '你是 Rei,用一句话提醒用户带伞', - apiUrl: 'https://api.openai.com/v1/chat/completions', - apiKey: '...', - primaryModel: 'gpt-4o-mini', - pushSubscription: subscription.toJSON(), -}); +## 目录 -// service-worker.js — 用 isContentPush 在收到推送时收窄类型 -import { isContentPush } from '@rei-standard/amsg-client'; - -self.addEventListener('push', (event) => { - const payload = event.data?.json(); - if (isContentPush(payload)) { - // payload 已被收窄为 ContentPush —— 安全读取 payload.message - event.waitUntil( - self.registration.showNotification(payload.contactName ?? 'Rei', { - body: payload.message, - }) - ); - } -}); -``` +- [快速使用](#快速使用) +- [`deliver()` 标准用法](#deliver-标准用法) +- [为什么需要 `deliver()`](#为什么需要-deliver) +- [`DeliverOptions` 全字段](#deliveroptions-全字段) +- [五种 `outcome` 含义](#五种-outcome-含义) +- [接观察通道的几种典型形态](#接观察通道的几种典型形态) +- [低级 API(`sendInstant` / `consumeInstantStream`)](#低级-apisendinstant--consumeinstantstream) +- [发送即时消息(加密 vs 明文)](#发送即时消息加密-vs-明文) +- [`messages` 多轮 / `splitPattern` 自定义分句](#messages-多轮--splitpattern-自定义分句) +- [本地软清空与可选 `maxPayloadBytes`](#本地软清空与可选-maxpayloadbytes) +- [其他工具:scheduleMessage / listMessages / subscribePush…](#其他工具) +- [模块格式与环境](#模块格式与环境) + +--- ## 安装 @@ -56,17 +42,30 @@ import { ReiClient } from '@rei-standard/amsg-client'; const client = new ReiClient({ baseUrl: '/api/v1', - userId: '550e8400-e29b-41d4-a716-446655440000' + userId: '550e8400-e29b-41d4-a716-446655440000', }); await client.init(); +``` + +发送即时消息(**推荐**走 `deliver()`,下一节展开): + +```js +const result = await client.deliver(payload, { + delivery: { mode: 'observed', observed: observationPromise }, + timeoutMs: 300_000, +}); +if (result.ok) { + // result.outcome === 'delivered' —— 真送达 +} +``` +订 Web Push(如果你的接入方案需要走 push 通道): + +```js await navigator.serviceWorker.register('/service-worker.js'); const registration = await navigator.serviceWorker.ready; -const subscription = await client.subscribePush( - window.__VAPID_PUBLIC_KEY__, - registration -); +const subscription = await client.subscribePush(window.__VAPID_PUBLIC_KEY__, registration); await client.scheduleMessage({ contactName: 'Rei', @@ -74,71 +73,339 @@ await client.scheduleMessage({ userMessage: '下班记得带伞~', firstSendTime: new Date(Date.now() + 60 * 1000).toISOString(), recurrenceType: 'none', - pushSubscription: subscription.toJSON() + pushSubscription: subscription.toJSON(), +}); +``` + +--- + +## `deliver()` 标准用法 + +```js +import { ReiClient } from '@rei-standard/amsg-client'; + +const client = new ReiClient({ baseUrl: 'https://instant.example.com', instantEncryption: false }); + +// 1. 准备「观察通道」Promise —— 任何能告诉你"消息已经进库 / 上屏 / 上通知中心"的来源都行。 +// 形状要求:resolve 时给一个 { messageId?, sessionId?, channel? };至少含其中一个 ID。 +const observationPromise = waitForReceipt({ /* 业务上下文 */ }); + +// 2. 发出消息并等送达裁决 +const abort = new AbortController(); +const result = await client.deliver(payload, { + delivery: { mode: 'observed', observed: observationPromise }, + timeoutMs: 300_000, // 整体预算 + onChunk: (chunk) => routeChunk(chunk), // 可选:SSE 每帧 UI 钩子;抛错被吞,不影响 outcome + signal: abort.signal, // 可选:caller 主动取消 +}); + +// 3. 五值 outcome —— 每一个都对应**明确**的业务动作 +switch (result.outcome) { + case 'delivered': + // 真送达。result.detail.receipt 是你自己 resolve 的那份。 + break; + case 'cancelled': + // 用户主动 abort,期间无延迟送达。安静返回,不弹错。 + break; + case 'timeout': + if (result.detail.observationChannelStalled) { + // ⚠ 重要分支:transport 干净结束但观察通道没接力。 + // 多半是 SW / IPC / native 推送处理那一侧挂了 / 卡了。 + // 不要当发送失败,提示"已发送,本机推送通道暂未确认"即可。 + } else { + // 整体预算耗完,啥信号都没等到。可重试。 + } + break; + case 'send-failed': + // transport 自己挂了(带 detail.transportError),并且没有观察到送达。 + // 这才是「真的发送失败」。 + showError(result.detail.transportError); + break; + case 'completed-unconfirmed': + // 仅 transport-only 模式才出现。下面专门讲。 + break; +} +``` + +`result.detail` 永远有,里面带 `waitedMs` / `transportEnded` / `transportError` / `transportResponse`(JSON 模式)/ `chunkHandlerError` / `cancelledByCaller` / `observationChannelStalled` / `receipt`,按需取诊断信息。 + +--- + +## 为什么需要 `deliver()` + +如果你的后端是 `@rei-standard/amsg-instant` 0.9.0+,**它默认强制开启 Web Push always-on backup**:同一条业务消息**总是**同时走两条通道下去—— + +1. SSE 流式直送(前台收到走 `event: payload`) +2. Web Push 备份(即使 SSE 成功 enqueue,也照样发一份,由 SW 端按 `messageId` 去重) + +这种双通道语义让旧的两条单一信号路径都不再可靠: + +| 旧 API | 看到的信号 | 实际意味着 | +| --- | --- | --- | +| `sendInstant()` 返回 `200` | dispatch 成功 | ❌ **不等于**消费者真收到(push backup 仍可能没到) | +| `consumeInstantStream()` reject | SSE 这条路断了 | ❌ **不等于**消息没送达(push backup 可能已到) | + +最朴素的 naive 代码 `try { await consumeInstantStream() } catch { fail() }` 在这套语义下**必然出错**——iOS 把后台 fetch 杀掉时,SSE reject,用户看到「失败」,但其实 push backup 已经把消息送进去了,过一会儿冒出来。计费、UI 文案、重试逻辑全部错乱。 + +`deliver()` 的解法: + +- **transport 只是辅助**——它的成败用来收紧延迟,不用来判送达 +- **送达由"观察通道"决定**——caller 提供一个 `Promise`,等业务上"真到了"才 resolve。这条 Promise 怎么实现库不关心,**真正平台无关** +- **race 四路 + grace + 严格 outcome**——返回值告诉你到底是 delivered / cancelled / timeout / send-failed / completed-unconfirmed 的哪一个,不再让 caller 自己脑补 + +--- + +## `DeliverOptions` 全字段 + +```ts +interface DeliverOptions { + delivery: + | { mode: 'observed'; observed: Promise } + | { mode: 'transport-only' }; + + timeoutMs: number; // 总预算(含 transport + grace) + onChunk?: (payload: unknown) => Promise | void; // 可选 SSE 每帧钩子,抛错被吞 + postTransportGraceMs?: number; // transport 结束后等观察的 grace + // 默认 = min(remaining, max(5000, timeoutMs * 0.1)) + // cancel 路径下生效的是 grace / 2 + signal?: AbortSignal; // 已 aborted → 立即 cancelled,不发 fetch + // listener 在每个终态会被卸载,长生命周期 signal 反复 + // 调用不会累积 + headers?: Record; // 额外请求头;可覆盖 Content-Type,但不能覆盖 + // X-User-Id / X-Payload-Encrypted / X-Encryption-Version + // / X-Client-Token / Authorization + authorization?: string; // 透传成 Authorization header(与 sendInstant 对齐) + endpointPath?: string; // 默认 '/instant',可改 '/continue' 续跑 +} + +interface ObservedDeliveryReceipt { + messageId?: string; // 至少一个非空字符串 + sessionId?: string; // ↑ + channel?: string; // 'sw' / 'ipc' / 'native' / 'poll' / 任意诊断 label +} +``` + +### `delivery.mode` 必须显式选 + +| mode | 何时用 | outcome 取值 | +| --- | --- | --- | +| `'observed'` | **99% 用户用这个**。有任何能确认"消息真到了"的 out-of-band 通道 | `delivered` / `cancelled` / `timeout` / `send-failed` | +| `'transport-only'` | 没有 out-of-band 通道(amsg-instant 0.9+ 默认场景几乎不会用到;某些自定义后端 / 调试场景才会) | `completed-unconfirmed` / `cancelled` / `timeout` / `send-failed` | + +> 库**不允许**「传一个永不 resolve 的 Promise 假装在 observed 模式」的写法——那等于教人写错代码。模式必须显式声明。 + +### `postTransportGraceMs` + +transport 结束后(无论干净结束还是 error)等观察通道的额外窗口。默认公式: + +``` +default = min(remainingBudget, max(5000ms, timeoutMs * 0.1)) +``` + +- 5 秒下限保住极短 timeout 下 grace 不被砍到 0 +- 10% 比例让 30s / 300s / 多分钟级 timeout 都有合理 grace +- caller 显式传时仍会被 `remainingBudget` cap,不会超出 `timeoutMs` 总预算 + +cancel 路径用的是 `grace / 2`(abort 后只给一半时间等延迟送达,剩下半给清理)。 + +--- + +## 五种 `outcome` 含义 + +| outcome | `ok` | 何时出现 | 推荐 caller 行为 | +| --- | --- | --- | --- | +| `'delivered'` | ✅ true | observed 模式 + 收到匹配 receipt(任何路径,包括 abort 后 grace 内仍到) | 正常成功路径 | +| `'cancelled'` | ❌ false | caller `signal.abort()` 触发,且 grace 内没观察到送达 | 安静返回,不弹错(这是用户主动) | +| `'timeout'` | ❌ false | 总预算耗完;**或** observed 模式 transport 干净结束但 observation 没接力 | 可重试;如带 `observationChannelStalled` 标记则提示「已发送、本机推送通道暂未确认」 | +| `'send-failed'` | ❌ false | transport 自己挂了(`detail.transportError` 有值)+ 没观察到送达 | 这才是真发送失败,给 `detail.transportError` 报错 | +| `'completed-unconfirmed'` | ❌ false | **仅 transport-only 模式**,transport 干净结束,无真相信号 | best-effort 乐观,caller 自决怎么判 | + +特别注意两个细分: + +- **`outcome:'timeout'` + `detail.observationChannelStalled:true`** —— transport 都好好结束了,是观察那一侧(SW / IPC / native push handler)没把信号给到 `observed`。多半是观察那侧的实现有问题,不是发送失败。文案应该跟普通 timeout 区分。 +- **`outcome:'delivered'` + `detail.cancelledByCaller:true`** —— 用户切走 / 关页面后,消息在 grace 内仍然送达了(实战常见:iOS Safari 切 tab,几百 ms 后 push 才到)。不算 cancelled。 + +--- + +## 接观察通道的几种典型形态 + +`deliver()` 不绑死任何平台。这一节给几个常见形态的 reference 写法——**库里都不内置,全是 caller 自己几行胶水**。 + +### Service Worker 广播 + +如果你的 SW 是 `@rei-standard/amsg-sw` 或类似实现,会在落库后 `postMessage` 一份 `{ type: 'REI_AMSG_PUSH', event: 'DELIVER', payload }`。把它包成 Promise: + +```js +function waitForSwReceipt(messageId, signal) { + return new Promise((resolve, reject) => { + function handler(e) { + if (e.data?.type !== 'REI_AMSG_PUSH') return; + const p = e.data.payload; + if (p?.messageId === messageId) { + navigator.serviceWorker.removeEventListener('message', handler); + resolve({ messageId: p.messageId, sessionId: p.sessionId, channel: 'sw' }); + } + } + navigator.serviceWorker.addEventListener('message', handler); + signal?.addEventListener('abort', () => { + navigator.serviceWorker.removeEventListener('message', handler); + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); +} + +await client.deliver(payload, { + delivery: { mode: 'observed', observed: waitForSwReceipt(payload.messageId, abort.signal) }, + timeoutMs: 300_000, }); ``` -## 发送即时消息 +### Electron / Tauri IPC -新代码用 `client.sendInstant(payload)`,走 [`@rei-standard/amsg-instant`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)。 +```js +function waitForIpcReceipt(messageId) { + return new Promise((resolve) => { + const off = window.ipcBridge.on('amsg:received', (p) => { + if (p.messageId !== messageId) return; + off(); + resolve({ messageId: p.messageId, channel: 'ipc' }); + }); + }); +} +``` + +### 原生 push 桥(React Native / native WebView) + +```js +function waitForNativeReceipt(messageId) { + return new Promise((resolve) => { + const sub = NativeEventEmitter.addListener('amsg-received', (p) => { + if (p.messageId !== messageId) return; + sub.remove(); + resolve({ messageId: p.messageId, channel: 'native' }); + }); + }); +} +``` + +### 纯轮询 fallback + +```js +function pollReceipt(messageId, signal) { + return new Promise((resolve, reject) => { + const t = setInterval(async () => { + if (signal.aborted) { clearInterval(t); reject(new DOMException('aborted', 'AbortError')); return; } + const found = await db.findReceipt(messageId); + if (found) { clearInterval(t); resolve({ messageId, channel: 'poll' }); } + }, 1000); + }); +} +``` + +`deliver()` 对这些一视同仁,只看 `Promise` 何时 resolve 出什么。 + +--- + +## 低级 API:`sendInstant` / `consumeInstantStream` + +这两个 API 仍然保留,但**只在以下情况推荐**: + +- 你已经在更上层自己接好了送达确认(典型:业务库直接同步落库后就算完成,根本没有"观察通道"概念) +- 你只需要 SSE 每帧的 UI 钩子,不需要 outcome 裁决 +- 临时调试 / one-off 脚本 + +不在这些情况下,**用 `deliver()`**。 + +### `sendInstant(payload, endpointPath?, opts?)` + +POST JSON 到 instant endpoint,原样返回 worker 的 `{ success, data?, error? }`。 + +> ⚠ **HTTP 200 ≠ delivery confirmation**,当 worker 配了 backup Web Push 时(amsg-instant 0.9.0+ 默认)。`200` 只说明 dispatch 成功,不说明消费者真收到。要正确判断送达,用 `deliver()`。 + +可选 `opts.expectsBackupPush`: +- 设 `true` —— 本实例此方法首次调用时 `console.warn` 一次,提醒上述陷阱(migration 期审计有用) +- 设 `false` —— 显式表示「我知道这点」,永久静音 +- 不传 —— 不警告 + +### `consumeInstantStream(payload, endpointPath?, options)` + +POST 并按 SSE 帧解析 `event: payload` / `event: done` / `event: error`,分发到 `options.onPayload`。 + +```js +try { + await client.consumeInstantStream(payload, '/instant', { + onPayload: async (push) => routePush(push), + onError: (err) => log.warn('stream error', err), + onDone: () => stopSpinner(), + signal: abort.signal, + }); +} catch (err) { + // ⚠ reject ≠ delivery failure(详见上面) +} +``` + +> ⚠ **rejection ≠ delivery failure**,当 worker 配了 backup Web Push 时。SSE 可能因为 iOS 杀后台 fetch、网络抖动、worker 5xx 而 reject,但 backup push 仍然把消息送到了。把 reject 当成「发送失败」会导致**虚报失败 + 消息晚到时用户困惑**。要正确判断送达,用 `deliver()`。 + +`opts.expectsBackupPush` 与 `sendInstant` 一致。 + +--- + +## 发送即时消息(加密 vs 明文) + +`deliver()` 与 `sendInstant` 共享同一套 transport 配置,由构造器决定: ### 加密模式(默认;兼容 amsg-server / amsg-instant 0.1.x) ```js const client = new ReiClient({ baseUrl: '/api/v1', - customBaseUrls: { - instant: 'https://instant.example.com', // 不传则用 baseUrl - }, + customBaseUrls: { instant: 'https://instant.example.com' }, userId: '550e8400-e29b-41d4-a716-446655440000', }); await client.init(); -await client.sendInstant({ +await client.deliver({ contactName: 'Rei', completePrompt: '你是 Rei,用一句话提醒用户带伞', apiUrl: 'https://api.openai.com/v1/chat/completions', apiKey: '...', primaryModel: 'gpt-4o-mini', pushSubscription: subscription.toJSON(), +}, { + delivery: { mode: 'observed', observed: observationPromise }, + timeoutMs: 300_000, }); ``` -> `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定 base URL,不会再加新的命名字段。 +> `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定。 -### 明文模式(配 amsg-instant 0.2.x,单租户自部署) +### 明文模式(配 amsg-instant 0.2.x+ / 单租户自部署) ```js const client = new ReiClient({ - baseUrl: 'https://instant.example.com', // amsg-instant Worker URL + baseUrl: 'https://instant.example.com', instantEncryption: false, - instantClientToken: 'shared-secret-xyz', // 可选;Worker 端配了再填 + instantClientToken: 'shared-secret-xyz', }); -// init() 在明文模式下是 no-op,调用与否都跑得通 -await client.sendInstant({ - contactName: 'Rei', - completePrompt: '你是 Rei,用一句话提醒用户带伞', - apiUrl: 'https://api.openai.com/v1/chat/completions', - apiKey: '...', - primaryModel: 'gpt-4o-mini', - pushSubscription: subscription.toJSON(), -}); +// init() 在明文模式下是 no-op,调不调都行 ``` -> ⚠️ **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。它只防 URL 直接被脚本小子打,不防有心人。要真正的鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配合后端签发短期 token)。 +> ⚠ **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。只防 URL 直怼,不防有心人。要真正鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配后端签发短期 token)。 + +> ⚠ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。同一前端两类方法都要用,请改回 `instantEncryption: true`(默认)。 -> ⚠️ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变成 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。如果同一前端既要 `sendInstant`(明文走 amsg-instant)又要 `scheduleMessage`(加密走 amsg-server),请改回 `instantEncryption: true`(默认)—— amsg-instant 0.1.x 与 amsg-server 用同一份 `userKey` 都吃得下。 +--- -旧路径 `scheduleMessage({ ...payload, messageType: 'instant' })` 仍然可用(兼容保留,多一次 DB 来回)。 +## `messages` 多轮 / `splitPattern` 自定义分句 -### `messages` 模式(多轮上下文 / 带 system role,对接 amsg-instant 0.5.0+ / amsg-server 2.2.0+) +`deliver()` / `sendInstant` / `consumeInstantStream` 都是 **payload-agnostic 透传**——这些字段写进 payload 就行,client 不校验,所有错误从 Worker / Server 端返回。 -需要 system role、保留多轮历史、tool role 这些场景时,把 `completePrompt` 换成标准 OpenAI 格式的 `messages` 数组。client 本身**完全透传**,所以 SDK 端零额外配置: +`messages`(OpenAI 格式数组): ```js -await client.sendInstant({ +await client.deliver({ contactName: 'Rei', messages: [ { role: 'system', content: '你是 Rei,回复要简短自然。' }, @@ -146,149 +413,94 @@ await client.sendInstant({ { role: 'assistant', content: '看了下,下午有阵雨。' }, { role: 'user', content: '那提醒我一下带伞' }, ], - apiUrl: 'https://api.openai.com/v1/chat/completions', + apiUrl: '...', apiKey: '...', primaryModel: 'gpt-4o-mini', - temperature: 0.7, // 可选 pushSubscription: subscription.toJSON(), -}); +}, { delivery: ..., timeoutMs: 300_000 }); ``` -注意 `completePrompt` 和 `messages` **必须恰好二选一**——两者同时给会被 Worker / Server 端返回 `400 INVALID_PAYLOAD_FORMAT` / `INVALID_PARAMETERS`。`scheduleMessage` 也接受同样的 `messages` 字段(amsg-server 2.2.0+ 起持久化层一并支持),用法相同。 - -### `splitPattern` 自定义分句正则(对接 amsg-instant 0.6.0+ / amsg-server 2.3.0+) +`completePrompt` 和 `messages` **必须恰好二选一**,同时给会被远端返回 `400 INVALID_PAYLOAD_FORMAT`。 -LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送。要换成别的正则(按换行、按段落、自定义符号……)就在 payload 里加 `splitPattern`: +`splitPattern`(自定义分句正则,`string | string[]`): ```js -// 单正则:按换行切 -await client.sendInstant({ - contactName: 'Rei', - completePrompt: '...', - splitPattern: '([\\n]+)', - // 其余字段同上 -}); - -// 数组级联:先按段落,每段再按句号 -await client.sendInstant({ - contactName: 'Rei', - completePrompt: '...', - splitPattern: ['(\\n\\n+)', '([。!?!?]+)'], -}); +splitPattern: '([\\n]+)', // 按换行 +splitPattern: ['(\\n\\n+)', '([。!?!?]+)'], // 数组级联:先段落、再句号 ``` -`splitPattern` 类型是 `string | string[]`。`scheduleMessage` 也支持,`updateMessage` 可显式传 `splitPattern: null` 重置回默认。client SDK 完全透传不校验,所有错误在 Worker / Server 端返回(每项 ≤ 200 字符、数组 ≤ 10 项、必须能 `new RegExp()` 通过)。 - **两个常见坑**: -- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。 +- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面斜杠 + 字面 `i`,不是大小写不敏感的 `foo`。要大小写不敏感请用 `[Aa]` 字符类。 - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 -### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+) +--- -`sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,前台延迟从约 1–3s(push service → SW → IDB → window)降到次百毫秒。Web Push backup 同时**常开 always-on**(即使 SSE enqueue 成功也照样发一份),用 SW / client 端按 `messageId` 做 dedupe 把两路收敛回一次。前台场景应该改用 `consumeInstantStream()`。 +## 本地软清空与可选 `maxPayloadBytes` -```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 时也走这条路兜底。 - -同一 `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`: +`scheduleMessage` / `sendInstant` / `consumeInstantStream` / `deliver` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;要本地护栏可在构造器显式传 `maxPayloadBytes`: ```js const client = new ReiClient({ baseUrl: '/api/v1', userId, - maxPayloadBytes: 256_000, // 可选;默认 null / 不限制 + 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` 不是字符串 | 同上 | 类型错误。 | -| 已配置 `maxPayloadBytes`,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 只在调用方主动需要本地请求体护栏时启用。Web Push 单条回复超限由 `amsg-instant` 的 BlobStore / multipart 输出链路处理,不靠 client 限制请求体。 | +| 触发条件 | 处理方式 | +| --- | --- | +| `payload.avatarUrl` 是 `data:` URI / 长度 > 2048 字符 / 非字符串 | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`(`updateMessage` 从 patch 里删除字段,保留服务端原头像),请求照发 | +| `maxPayloadBytes` 配了,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛 `Error` with `.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,`.details = { method, actualBytes, limitBytes }` | -头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。未配置 `maxPayloadBytes` 时不会产生 `PAYLOAD_TOO_LARGE_LOCAL`;配置后照常用 try/catch 捕获: +头像是装饰字段,单个不合规 URL 不再让整次调用挂掉。要拦错请监听 `console.warn`。 ```js try { - await client.sendInstant(payload); + await client.deliver(payload, { delivery, timeoutMs: 300_000 }); } catch (err) { if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') { - // err.details = { method: 'sendInstant', actualBytes: 87320, limitBytes: 256000 } - } else { - throw err; - } + // err.details = { method: 'deliver', actualBytes: 87320, limitBytes: 256000 } + } else { throw err; } } ``` -服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0+)有同样的软清空二道防线,client 这一道主要省一次远端往返。 +--- -## 导出 API(Exports) +## 其他工具 -- `ReiClient` +`ReiClient` 还有这些方法(与 2.4.x 相比无字节变化): -`ReiClient` 主要方法: +- `scheduleMessage(payload)` —— 排定 fixed / prompted / auto / instant 任务,加密走 amsg-server +- `updateMessage(uuid, updates)` —— 改任务字段 +- `cancelMessage(uuid)` —— 取消任务 +- `listMessages(opts)` —— 拉当前 user 的任务列表 +- `subscribePush(vapidPublicKey, registration)` —— 标准 Push API 订阅封装 -- `init()` -- `scheduleMessage(payload)` -- `sendInstant(payload)` -- `updateMessage(uuid, updates)` -- `cancelMessage(uuid)` -- `listMessages(opts)` -- `subscribePush(vapidPublicKey, registration)` +以及从 `@rei-standard/amsg-shared` re-export 的运行时常量 / builder / type guard: -## 模块格式与类型(ESM/CJS/Types) +- `MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE` +- `buildContentPush` / `buildReasoningPush` / `buildToolRequestPush` / `buildErrorPush` +- `isContentPush` / `isReasoningPush` / `isToolRequestPush` / `isErrorPush` + +这些在 SW / app 端处理 push 时用得上,单独装 `@rei-standard/amsg-shared` 没必要。 + +--- + +## 模块格式与环境 - ESM:`import { ReiClient } from '@rei-standard/amsg-client'` - CJS:`const { ReiClient } = require('@rei-standard/amsg-client')` - 类型:包内提供 `types` 入口(`dist/index.d.ts`) - -## 运行环境与要求 - -- 浏览器环境(需 `fetch`、`crypto.subtle`) +- 浏览器环境(需 `fetch`、`crypto.subtle`、`ReadableStream`、`AbortController`) - Push 订阅需可用 Service Worker 与 Push API -- 需要可用的 `baseUrl`(示例:`/api/v1`;明文 instant 模式下可直接是 Worker URL) - `userId` 必须是 UUID v4(明文 instant 模式 `instantEncryption: false` 下可省) -## 相关链接(绝对 URL) +## 相关链接 - [SDK Workspace 总览](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/README.md) - [Server 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/README.md) +- [Instant 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md) - [SW 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md) - [Service Worker 规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/service-worker-specification.md) - [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md) diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index c076ddf..7493054 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.4.0", + "version": "2.5.0-next.0", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 5354632..0cb1d51 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -6,6 +6,12 @@ * schedule path and amsg-instant 0.1.x) * - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false) * - Push subscription management via the Push API + * - The platform-agnostic `deliver()` delivery primitive (2.5.0+) which + * coordinates the foreground transport (SSE / JSON) with an out-of-band + * observation channel (SW broadcast / IPC / native push / polling …) + * so callers get a single correct outcome instead of having to second- + * guess "did the message actually land?". See the README's `deliver()` + * section for usage and the five-outcome contract. * * Usage: * import { ReiClient } from '@rei-standard/amsg-client'; @@ -50,20 +56,139 @@ * `updateMessage` always). Can be omitted only when * `instantEncryption: false` AND you do not call any * encrypted method. - * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON - * to amsg-instant 0.2.x. `init()` becomes a no-op. - * All other methods (`scheduleMessage` etc.) keep - * using AES-256-GCM regardless of this flag. + * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` / `deliver()` post + * plaintext JSON to amsg-instant 0.2.x+. `init()` + * becomes a no-op. All other methods + * (`scheduleMessage` etc.) keep using AES-256-GCM + * regardless of this flag. * @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by - * `sendInstant()` in plaintext mode. Note: this is - * 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. + * `sendInstant()` / `deliver()` in plaintext mode. + * Note: this is 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. */ +/** + * @typedef {Object} ObservedDeliveryReceipt + * Receipt produced by the caller's out-of-band observation channel and + * fed back to `deliver()` via `opts.delivery.observed`. Identifies the + * delivered message so a concurrent send's signal cannot accidentally + * satisfy this call's await. + * + * Identity requirement: **at least one** of `messageId` or `sessionId` + * MUST be a non-empty string. A receipt with neither is invalid — the + * library treats it as if the observed Promise never resolved (the race + * keeps waiting). This is enforced at runtime; an empty receipt is a + * caller-side bug, not a successful delivery. + * + * @property {string} [messageId] - Stable per-message identifier. + * @property {string} [sessionId] - Session-scoped delivery identifier. + * @property {string} [channel] - Free-form origin label for diagnostics + * (e.g. 'sw', 'ipc', 'native', 'poll'). + * Not validated by the library. + */ + +/** + * @typedef {'delivered' + * | 'completed-unconfirmed' + * | 'timeout' + * | 'cancelled' + * | 'send-failed'} DeliveryOutcome + * + * Terminal outcome of a `deliver()` call. + * + * - **delivered** — observed-mode only: the caller's observation channel + * produced a valid receipt within budget. Truth-grade success. + * - **completed-unconfirmed** — transport-only mode only: transport + * reached natural EOF cleanly but there is no truth signal to confirm + * downstream consumption. Best-effort optimistic; the caller decides + * how to interpret it. + * - **timeout** — total budget exhausted with no terminal signal; or, + * in observed mode, transport ended cleanly but the observation + * channel failed to deliver within grace (the observation pipeline + * may itself be broken — this is NOT classified as send-failed). + * `detail.observationChannelStalled` is set in the latter case. + * - **cancelled** — caller `signal.abort()` fired and no delivery was + * observed within the cancellation grace. + * - **send-failed** — transport rejected (with a captured error) AND no + * observed delivery landed within grace. Only fires when transport + * has a real error — a clean transport is never `send-failed`. + */ + +/** + * @typedef {Object} DeliveryResultDetail + * @property {number} waitedMs - Wall-clock ms spent in `deliver()`. + * @property {boolean} [transportEnded] - True if transport reached natural EOF (vs aborted / errored). + * @property {unknown} [transportError] - Non-null if transport rejected. + * @property {unknown} [transportResponse] - For JSON transport, the parsed response body. + * @property {unknown} [chunkHandlerError] - Non-null if `onChunk` threw at any point. Never promotes the outcome. + * @property {boolean} [cancelledByCaller] - True if caller's signal aborted before terminal outcome. + * @property {boolean} [observationChannelStalled] - True when observed-mode transport ended clean but observation never produced a valid receipt within grace. + * @property {ObservedDeliveryReceipt} [receipt] - Receipt as observed (for the `delivered` case). + */ + +/** + * @typedef {Object} DeliveryResult + * @property {boolean} ok - True iff outcome === 'delivered'. + * @property {DeliveryOutcome} outcome + * @property {DeliveryResultDetail} detail - Always populated; gives diagnostic context regardless of outcome. + */ + +/** + * @typedef {Object} ObservedDeliverySpec + * @property {'observed'} mode - Standard path with truth signal. + * @property {Promise} observed - Resolves with a receipt when the message is + * observed landed via the canonical out-of-band + * channel for this platform (SW broadcast / IPC / + * native push / polling …). The library does not + * care what produces this promise — it just races + * its settlement against transport / timeout / + * abort. + */ + +/** + * @typedef {Object} TransportOnlyDeliverySpec + * @property {'transport-only'} mode - Non-standard / advanced. No out-of-band signal is supplied. + * In this mode `outcome:'delivered'` will NEVER be returned (no truth + * signal); a clean transport yields `completed-unconfirmed`. + */ + +/** + * @typedef {Object} DeliverOptions + * @property {ObservedDeliverySpec | TransportOnlyDeliverySpec} delivery - Discriminated union — caller must + * explicitly pick a mode. There is no implicit default to discourage "I forgot to wire up observation". + * @property {number} timeoutMs - Total budget in ms (transport + post-transport grace). + * @property {(payload: unknown) => Promise | void} [onChunk] - Optional inline chunk handler for the + * foreground SSE transport. If omitted, transport still runs (for delivery effects) but no per-chunk UI + * hook fires. Throws are captured into `detail.chunkHandlerError` and do NOT promote the outcome to + * `'send-failed'` — UI hook failures are caller-bug-shaped, not transport failures. Not invoked for the + * JSON transport. + * @property {number} [postTransportGraceMs] - After transport settles (clean OR error), max wait for + * the observation channel before declaring failure. Default = `min(remainingBudget, max(5000, timeoutMs * 0.1))`. + * The 5s floor protects the grace from being slashed to ~0 by very short timeouts; the 10% scale gives + * sensible grace across 30s / 300s / multi-minute budgets. **Cancel-path note**: when the caller's + * `signal` fires before any other terminal event, the late-receipt window after the abort is + * `grace / 2` (the other half is reserved for cleanup). Tune this knob with that halving in mind if + * you care about late-arriving receipts after user cancellation. + * @property {AbortSignal} [signal] - Cooperative cancellation. Pre-flight: if already aborted + * at entry, returns `cancelled` synchronously without dispatching transport. Listeners added to this + * signal are removed on every terminal outcome, so a long-lived signal reused across many calls does + * not accumulate stale handlers. + * @property {Record} [headers] - Extra request headers forwarded into the underlying fetch. + * Caller-supplied keys are merged AFTER `Content-Type`/encryption headers, so they can override + * `Content-Type` but NOT `X-User-Id` / `X-Payload-Encrypted` / `X-Encryption-Version` / `X-Client-Token` + * / `Authorization` (use the `authorization` option for the last one). + * @property {string} [authorization] - Optional `Authorization` header forwarded as-is. Mirrors + * `sendInstant`'s `opts.authorization` so migrations from `sendInstant({authorization: ...})` to + * `deliver()` don't silently drop the header. + * @property {string} [endpointPath='/instant'] - Path under the resolved instant base URL. Pass + * `'/continue'` for tool-result resume on amsg-instant 0.9.0+. + */ + /** * Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors * `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side @@ -79,6 +204,37 @@ function makeLocalError(code, message, details) { return err; } +function isThenable(value) { + return !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function'; +} + +/** + * Per SSE spec, a single line terminator is `\r\n`, `\n`, or `\r`; + * an event ends with TWO consecutive terminators. We normalize the + * buffer to LF-only before framing so the split logic stays a simple + * `'\n\n'` (a `{2}` quantifier on alternations backtracks and would + * mis-treat a lone `\r\n` as two terminators). + */ +const SSE_LINE_NORMALIZE = /\r\n?/g; + +/** + * Classify a Content-Type value as 'sse' (text/event-stream), 'json' + * (application/json + structured-suffix variants like application/problem+json, + * application/vnd.api+json), or 'unknown'. + * + * Properly parses the media-type: splits on `;` to drop parameters, trims, + * lowercases, then exact-matches. A naïve substring search over the whole + * header would mis-classify e.g. `application/json; note=text/event-stream` + * as SSE, or `text/plain; x=application/json` as JSON. + */ +function classifyContentType(contentType) { + const main = (contentType || '').split(';')[0].trim().toLowerCase(); + if (main === 'text/event-stream') return 'sse'; + if (main === 'application/json') return 'json'; + if (/^application\/[\w.+-]+\+json$/.test(main)) return 'json'; + return 'unknown'; +} + export class ReiClient { /** * @param {ReiClientConfig} config @@ -116,6 +272,12 @@ export class ReiClient { : ''; /** @private */ this._maxPayloadBytes = normalizeMaxPayloadBytes(config.maxPayloadBytes); + /** + * Per-instance latch (set of method names already warned). The + * low-level dev warning fires at most once per ReiClient per method. + * @private @type {Set} + */ + this._lowLevelWarned = new Set(); } /** @@ -136,10 +298,11 @@ export class ReiClient { * Must be called before any encrypted request. * * In plaintext-instant mode (`instantEncryption: false`) this is a no-op: - * `sendInstant()` does not need a userKey. Note that if you also intend to - * call `scheduleMessage` / `listMessages` / `updateMessage` (which always - * use AES-256-GCM), you must construct with `instantEncryption: true` - * (the default) — those methods will throw "Not initialised" otherwise. + * `sendInstant()` / `deliver()` do not need a userKey. Note that if you + * also intend to call `scheduleMessage` / `listMessages` / `updateMessage` + * (which always use AES-256-GCM), you must construct with + * `instantEncryption: true` (the default) — those methods will throw + * "Not initialised" otherwise. */ async init() { if (this._instantEncryption === false) { @@ -167,11 +330,11 @@ export class ReiClient { /** * Schedule a message. * - * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead. - * That routes through `@rei-standard/amsg-instant` (stateless, no DB - * round-trip) rather than `amsg-server`'s schedule-message endpoint. - * This method still works for instant via amsg-server for backward - * compatibility — see CHANGELOG / README for details. + * Note: For `messageType: 'instant'`, prefer `deliver()` (2.5.0+) or + * `sendInstant()`. Both route through `@rei-standard/amsg-instant` + * (stateless, no DB round-trip) rather than `amsg-server`'s schedule- + * message endpoint. This method still works for instant via amsg-server + * for backward compatibility — see CHANGELOG / README for details. * * The payload is automatically encrypted before transmission. * @@ -204,12 +367,19 @@ export class ReiClient { } /** - * Send a one-shot instant message via `@rei-standard/amsg-instant`. + * **Low-level JSON dispatcher.** Use `deliver()` for new code — it + * gives you a correct `send-failed` vs `delivered` verdict by + * coordinating transport with an out-of-band observation channel. * - * Compared to `scheduleMessage({ messageType: 'instant', ... })`: - * - No DB round-trip on the server side (stateless) - * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge - * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`) + * Posts an instant message via `@rei-standard/amsg-instant` and + * returns whatever the worker returns. **HTTP 200 ≠ delivery + * confirmation** when amsg-instant is configured with backup Web + * Push (default in 0.9.0+): the dispatch succeeded but the message + * may still land via the backup channel even if this call rejected, + * and a 200 here does not guarantee the consumer ever saw it. If + * you only care about the transport response (no delivery + * coordination needed), this stays useful — otherwise prefer + * `deliver()`. * * Two transport modes (chosen by constructor `instantEncryption`): * @@ -219,65 +389,53 @@ export class ReiClient { * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`. * * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw - * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if + * JSON. Targets amsg-instant 0.2.x+. Sends `X-Client-Token` if * `instantClientToken` was configured. * * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`. * - * 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. 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'. - * @param {{ authorization?: string }} [opts] - Optional auth header to forward. + * @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts] + * - `authorization`: optional auth header to forward. + * - `expectsBackupPush`: opt-in flag for the dev warning. Set to `true` + * to log a one-shot console.warn confirming you understand the + * "200 ≠ delivered" pitfall and have your own out-of-band check + * (or migrated to `deliver()`). Set to `false` to explicitly silence + * the warning (you have read this contract). * @returns {Promise} `{ success, data?: { messagesSent, sentAt }, error? }` */ async sendInstant(payload, endpointPath = '/instant', opts = {}) { - this._sanitizeAvatarUrl(payload); - const json = JSON.stringify(payload); - this._assertPayloadSize(json, 'sendInstant'); - - const headers = { 'Content-Type': 'application/json' }; - 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); - } - - if (opts.authorization) { - headers['Authorization'] = opts.authorization; - } + this._maybeWarnLowLevel('sendInstant', opts); - const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`; - const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, { - method: 'POST', - headers, - body - }); + const { url, headers, body } = await this._buildInstantRequest( + payload, + endpointPath, + { authorization: opts.authorization, methodName: 'sendInstant' } + ); + const res = await fetch(url, { method: 'POST', headers, body }); return res.json(); } /** - * Consume an instant SSE stream. + * **Low-level SSE consumer.** Use `deliver()` for new code — it gives + * you a correct `send-failed` vs `delivered` verdict by coordinating + * transport with an out-of-band observation channel. + * + * **Rejection ≠ delivery failure** when amsg-instant is configured + * with backup Web Push (default in 0.9.0+): SSE may reject for many + * unrelated reasons (iOS background tab killed fetch, network blip, + * worker 5xx) while the backup push still lands the message. Treating + * the rejection as the canonical error path is wrong for that worker + * configuration. If you need the foreground SSE chunk hook without + * delivery coordination (you have your own observed channel), this + * stays useful — otherwise prefer `deliver()`. * * 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. + * callback throwing) rejects the returned Promise. `options.onError` + * fires before the rejection as a side-channel notification — it does + * NOT suppress the throw. Always wrap calls in `try / await`. * * @param {Object} payload - Instant message payload. * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'. @@ -287,31 +445,22 @@ export class ReiClient { * @param {(error: unknown) => void} [options.onError] * @param {() => void} [options.onDone] * @param {AbortSignal} [options.signal] + * @param {boolean} [options.expectsBackupPush] - Opt-in flag for the dev warning. + * Set to `true` to log a one-shot console.warn confirming you understand the + * "rejection ≠ delivery failure" pitfall and have your own check (or migrated + * to `deliver()`). Set to `false` to explicitly silence the warning. * @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; + this._maybeWarnLowLevel('consumeInstantStream', options); - 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 { url, headers, body } = await this._buildInstantRequest( + payload, + endpointPath, + { headers: options.headers, methodName: 'consumeInstantStream' } + ); - const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`; - const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, { + const res = await fetch(url, { method: 'POST', headers, body, @@ -324,7 +473,7 @@ export class ReiClient { } const contentType = res.headers.get('content-type') || ''; - if (!contentType.includes('text/event-stream')) { + if (classifyContentType(contentType) !== 'sse') { const text = await res.text().catch(() => ''); throw new Error(`Expected text/event-stream, got ${contentType}: ${text}`); } @@ -333,88 +482,276 @@ export class ReiClient { 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; + await this._consumeSseStream(res, { onPayload: options.onPayload }); + if (options.onDone) options.onDone(); + } catch (err) { + if (options.onError) { + try { options.onError(err); } catch { /* observer can't break the throw */ } + } + throw err; + } + } - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split('\n\n'); - buffer = parts.pop() || ''; // last part may be incomplete + /** + * Deliver a message with an explicit delivery contract. + * + * `deliver()` is the recommended primitive for new code. It coordinates + * the foreground transport (SSE / JSON, picked automatically by + * response Content-Type) with an optional out-of-band observation + * channel that the caller supplies as a Promise — the library doesn't + * care what produces that Promise (Service Worker broadcast, IPC, + * native push handler, polling, anything). It returns a single + * `DeliveryResult` with a five-value `outcome` so you can distinguish + * `delivered` (truth-grade) from `cancelled` / `timeout` / `send-failed` + * without inferring delivery from transport rejections. + * + * Why this exists: when the server uses always-on backup Web Push + * (amsg-instant 0.9.0+ default), `sendInstant`'s HTTP 200 and + * `consumeInstantStream`'s rejection are both ambiguous w.r.t. actual + * delivery — the backup channel can still deliver after a transport + * reject, and a clean transport doesn't prove the consumer ever + * observed the message. `deliver()` resolves that ambiguity by + * making the observation channel a first-class input. + * + * @param {Object} payload - Instant message payload (same shape as `sendInstant`). + * @param {DeliverOptions} opts - Delivery contract; see typedef. + * @returns {Promise} + */ + async deliver(payload, opts) { + if (!opts || typeof opts !== 'object') { + throw new TypeError('[rei-standard-amsg-client] deliver() requires an options object'); + } + const { + delivery, timeoutMs, onChunk, postTransportGraceMs, + signal, headers, authorization, endpointPath, + } = opts; - 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 (!delivery || typeof delivery !== 'object') { + throw new TypeError('[rei-standard-amsg-client] deliver() requires opts.delivery (discriminated union)'); + } + if (delivery.mode !== 'observed' && delivery.mode !== 'transport-only') { + throw new TypeError( + '[rei-standard-amsg-client] opts.delivery.mode must be "observed" or "transport-only"' + ); + } + if (delivery.mode === 'observed' && !isThenable(delivery.observed)) { + throw new TypeError( + '[rei-standard-amsg-client] opts.delivery.observed must be a Promise' + ); + } + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new TypeError('[rei-standard-amsg-client] opts.timeoutMs must be a positive finite number'); + } + if ( + postTransportGraceMs !== undefined && + (typeof postTransportGraceMs !== 'number' || !Number.isFinite(postTransportGraceMs) || postTransportGraceMs < 0) + ) { + throw new TypeError( + '[rei-standard-amsg-client] opts.postTransportGraceMs, if set, must be a non-negative finite number' + ); + } - if (eventName === 'done') { - if (options.onDone) options.onDone(); - return; - } + const start = Date.now(); + const detail = { waitedMs: 0 }; - 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; - } + // Pre-flight: don't dispatch transport if signal is already aborted. + if (signal && signal.aborted) { + detail.cancelledByCaller = true; + return { ok: false, outcome: 'cancelled', detail }; + } - if (eventName === 'payload') { - let parsedPayload; - try { - parsedPayload = JSON.parse(data); - } catch { - continue; - } - if (options.onPayload) { - await options.onPayload(parsedPayload); - } - } + // Build the request synchronously so local-validation errors + // (PAYLOAD_TOO_LARGE_LOCAL, encryption "Not initialised") surface + // as thrown Errors from deliver() itself — not buried inside a + // post-grace `send-failed` detail.transportError. + const built = await this._buildInstantRequest( + payload, + endpointPath || '/instant', + { headers, authorization, methodName: 'deliver' } + ); + + // Second abort check: `_buildInstantRequest` is `await`ed + // (encrypted path does Web Crypto), so the caller's signal may + // have aborted while we were building. Catching it here keeps + // the "aborted before dispatch ⇒ no fetch" contract honest. + if (signal && signal.aborted) { + detail.cancelledByCaller = true; + detail.waitedMs = Date.now() - start; + return { ok: false, outcome: 'cancelled', detail }; + } + + // ---- Once `finalized` flips, no late closure may mutate caller-visible + // `detail` — caller holds it by reference and we don't want a post-return + // write to flip transportResponse / chunkHandlerError underneath them. + let finalized = false; + + // ---- Observation promise (observed mode only — no NEVER_SETTLES + // retention in transport-only). ---- + let validatedObserved = null; + let observedP = null; + if (delivery.mode === 'observed') { + validatedObserved = this._waitForValidReceipt(delivery.observed); + observedP = validatedObserved.then((receipt) => ({ tag: 'delivered', receipt })); + } + + // ---- onChunk wrapping (errors captured, never propagated; gated on + // `finalized` so a late throw can't mutate caller-held detail). ---- + const wrappedOnChunk = onChunk + ? async (chunk) => { + try { await onChunk(chunk); } + catch (err) { + if (finalized) return; + if (detail.chunkHandlerError === undefined) detail.chunkHandlerError = err; } } + : undefined; + + // ---- Transport plumbing ---- + const internalAbort = new AbortController(); + let transportEnded = false; + let transportError; + + const transportPromise = (async () => { + try { + const result = await this._runInstantTransport(built, { + signal: internalAbort.signal, + onChunk: wrappedOnChunk, + }); + if (finalized) return; + transportEnded = true; + if (result && result.kind === 'json') detail.transportResponse = result.body; + } catch (err) { + if (finalized) return; + transportError = err; + } + })(); - // 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 */ } + // ---- Race plumbing ---- + let timeoutId; + const timeoutP = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve({ tag: 'timeout' }), timeoutMs); + }); + + // Track signal listeners so we can remove them on every terminal + // outcome — otherwise a long-lived signal reused across many calls + // accumulates {once:true} handlers that retain our closures. + const signalListeners = []; + let cancelledP = null; + if (signal) { + cancelledP = new Promise((resolve) => { + const cancelListener = () => resolve({ tag: 'cancelled' }); + const abortForwarder = () => internalAbort.abort(); + signal.addEventListener('abort', cancelListener, { once: true }); + signal.addEventListener('abort', abortForwarder, { once: true }); + signalListeners.push(cancelListener, abortForwarder); + // DOM spec: addEventListener('abort', ...) on an already-aborted + // signal does NOT fire. Pre-flight covered "aborted before entry", + // but the microtask window between that check and these adds is + // observable — fire the listeners synchronously if we missed it. + if (signal.aborted) { + cancelListener(); + abortForwarder(); } - try { reader.releaseLock(); } catch { /* already released */ } - throw thrown; + }); + } + + const transportP = transportPromise.then(() => ({ tag: 'transport-ended' })); + + // Build the race conditionally — only include racers that can actually + // settle, so we don't attach handlers to a forever-pending Promise and + // leak reactions across many calls. + const racers = [transportP, timeoutP]; + if (observedP) racers.push(observedP); + if (cancelledP) racers.push(cancelledP); + + const winner = await Promise.race(racers); + + // ---- Terminal-state finalization ---- + // `finalize` is the single exit gate. Sets `finalized=true` before + // any return so the still-running transport IIFE / onChunk callback + // can't mutate the caller-held detail; clears the main-timeout timer; + // aborts the transport for cleanup; removes the caller-signal + // listeners we attached; stamps waitedMs + transport status onto + // detail; returns. + const finalize = (outcome, ok, extras) => { + finalized = true; + clearTimeout(timeoutId); + internalAbort.abort(); + if (signal) { + for (const l of signalListeners) signal.removeEventListener('abort', l); } - try { reader.releaseLock(); } catch { /* already released */ } + detail.waitedMs = Date.now() - start; + if (transportEnded) detail.transportEnded = true; + if (transportError !== undefined) detail.transportError = transportError; + if (extras) Object.assign(detail, extras); + return { ok, outcome, detail }; + }; + + const remainingBudget = () => Math.max(0, timeoutMs - (Date.now() - start)); + + // ── Winner: delivered ──────────────────────────────────────── + if (winner.tag === 'delivered') { + return finalize('delivered', true, { receipt: winner.receipt }); + } + + // ── Winner: cancelled ──────────────────────────────────────── + if (winner.tag === 'cancelled') { + // Cancel-grace window: late receipt may still arrive in the + // microtask after abort. Only meaningful in observed mode — no + // observation channel exists in transport-only, so the wait is + // pure dead time and we short-circuit. + detail.cancelledByCaller = true; + if (validatedObserved) { + internalAbort.abort(); + const cancelGrace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget()) / 2; + const lateReceipt = await this._raceObservedWithTimeout(validatedObserved, cancelGrace); + if (lateReceipt) { + return finalize('delivered', true, { receipt: lateReceipt }); + } + } + return finalize('cancelled', false); } + + // ── Winner: timeout ────────────────────────────────────────── + if (winner.tag === 'timeout') { + return finalize('timeout', false); + } + + // ── Winner: transport-ended ────────────────────────────────── + clearTimeout(timeoutId); + + // Transport-only: no observation channel can ever settle, so the + // grace wait is pure dead time. Decide the outcome from the + // transport result immediately. + if (!validatedObserved) { + if (transportError !== undefined) return finalize('send-failed', false); + return finalize('completed-unconfirmed', false, { transportEnded: true }); + } + + // Observed mode: wait up to `grace` for a late receipt, but keep the + // caller's cancel signal in the race — otherwise an abort during + // grace is silently downgraded to timeout / send-failed. + const grace = this._computeGrace(postTransportGraceMs, timeoutMs, remainingBudget()); + const observedLateP = this._raceObservedWithTimeout(validatedObserved, grace) + .then((receipt) => ({ tag: 'late', receipt })); + const lateRacers = [observedLateP]; + if (cancelledP) lateRacers.push(cancelledP); + const lateWinner = await Promise.race(lateRacers); + + if (lateWinner.tag === 'cancelled') { + detail.cancelledByCaller = true; + return finalize('cancelled', false); + } + if (lateWinner.receipt) { + return finalize('delivered', true, { receipt: lateWinner.receipt }); + } + if (transportError !== undefined) { + // Case A: transport had captured error → real send failure + return finalize('send-failed', false); + } + // Case C: observed mode + clean transport + missing observation = stalled + return finalize('timeout', false, { transportEnded: true, observationChannelStalled: true }); } /** @@ -582,6 +919,310 @@ export class ReiClient { } } + // ─── Transport helpers (shared by sendInstant / consumeInstantStream / deliver) ─ + + /** + * Build the URL, headers, and body for an instant-endpoint POST. + * Used by `sendInstant`, `consumeInstantStream`, and `deliver`. + * + * @private + * @param {Object} payload + * @param {string} endpointPath + * @param {{ headers?: Record, authorization?: string, methodName: string }} opts + * @returns {Promise<{ url: string, headers: Record, body: string }>} + */ + async _buildInstantRequest(payload, endpointPath, opts) { + const { headers: extraHeaders, authorization, methodName } = opts; + this._sanitizeAvatarUrl(payload); + const json = JSON.stringify(payload); + this._assertPayloadSize(json, methodName); + + const headers = { 'Content-Type': 'application/json', ...(extraHeaders || {}) }; + 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); + } + + if (authorization) headers['Authorization'] = authorization; + + const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`; + const url = `${this._resolveBaseUrl('instant')}${path}`; + return { url, headers, body }; + } + + /** + * Run the foreground transport for `deliver()`. Takes a request pre-built + * by `_buildInstantRequest` so the caller can surface local-validation + * errors (encryption, payload-size) synchronously, instead of having + * them buried inside the post-transport grace race. + * Picks SSE or JSON based on the response Content-Type. Resolves on + * natural stream EOF / parsed JSON; throws on network / protocol / SSE + * error frame / AbortError. + * + * @private + * @param {{ url: string, headers: Record, body: string }} built + * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise | void }} opts + * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>} + */ + async _runInstantTransport(built, opts) { + const { signal, onChunk } = opts; + const { url, headers, body } = built; + + const res = await fetch(url, { method: 'POST', headers, body, signal }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + const err = new Error(`Instant request failed: ${res.status} ${text}`); + err.status = res.status; + throw err; + } + + const contentType = res.headers.get('content-type') || ''; + const kind = classifyContentType(contentType); + if (kind === 'sse') { + if (!res.body) throw new Error('Response body is null'); + await this._consumeSseStream(res, { onPayload: onChunk }); + return { kind: 'sse' }; + } + if (kind === 'json') { + const json = await res.json(); + return { kind: 'json', body: json }; + } + const text = await res.text().catch(() => ''); + throw new Error(`Expected text/event-stream or application/json, got ${contentType}: ${text}`); + } + + /** + * Consume an SSE response body, dispatching `event: payload` frames to + * `onPayload`. Resolves on `event: done` or natural EOF. Throws on + * `event: error` frames, `onPayload` throws, or stream read errors. + * + * @private + * @param {Response} res + * @param {{ onPayload?: (p: unknown) => Promise | void }} opts + * @returns {Promise} + */ + async _consumeSseStream(res, opts) { + const { onPayload } = opts; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let thrown; + + // Parse one SSE frame body (lines between two terminators). Returns + // `'done'` if the frame signals end-of-stream so the caller can + // unwind without consuming further frames. Throws on `event: error`. + // Assumes the input has already been line-normalized to LF. + const processFrame = async (part) => { + if (!part.trim()) return null; + 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 `_consumeSseStream` 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') return 'done'; + 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 { return null; } + if (onPayload) await onPayload(parsedPayload); + } + return null; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Flush any tail bytes the decoder held back (partial UTF-8 + // sequences split across the final chunk boundary). + buffer += decoder.decode(); + // Some servers close without a final blank-line terminator; + // the trailing buffer may still contain a complete frame body. + const finalNormalized = buffer.replace(SSE_LINE_NORMALIZE, '\n'); + if (finalNormalized.trim()) { + await processFrame(finalNormalized); + } + return; + } + buffer += decoder.decode(value, { stream: true }); + // A `\r` at the very end of the buffer is ambiguous — it might be + // a standalone CR line terminator, OR the first byte of a CRLF + // whose `\n` is in the next chunk. Defer normalization of that one + // trailing byte until either more data arrives or the stream ends. + const trailingCr = buffer.endsWith('\r'); + const head = trailingCr ? buffer.slice(0, -1) : buffer; + const normalized = head.replace(SSE_LINE_NORMALIZE, '\n'); + const parts = normalized.split('\n\n'); + // Re-attach the deferred `\r` to whatever remains incomplete. + buffer = (parts.pop() || '') + (trailingCr ? '\r' : ''); + for (const part of parts) { + const result = await processFrame(part); + if (result === 'done') return; + } + } + } catch (err) { + thrown = err; + } finally { + if (thrown) { + try { await reader.cancel(thrown); } catch { /* already cancelled */ } + try { reader.releaseLock(); } catch { /* already released */ } + throw thrown; + } + try { reader.releaseLock(); } catch { /* already released */ } + } + } + + // ─── deliver() helpers ────────────────────────────────────────── + + /** + * Wraps the caller's observed Promise so it only settles on a valid + * `ObservedDeliveryReceipt` (per RFC: at least one of messageId / + * sessionId must be a non-empty string). Invalid receipts and + * rejections leave the returned Promise pending — the race's timeout + * or abort branches take over. + * + * @private + * @param {Promise} source + * @returns {Promise} + */ + _waitForValidReceipt(source) { + return new Promise((resolve) => { + Promise.resolve(source).then( + (receipt) => { + if (this._validateReceipt(receipt)) { + resolve(receipt); + } + // invalid receipt: never resolve — treat as if observed never fired + }, + () => { + // observed rejected: never resolve — race's timeout/abort take over + } + ); + }); + } + + /** + * Identity check: a receipt must be an object with at least one of + * `messageId` or `sessionId` as a non-empty string. Caller-supplied + * observation channels can produce arbitrary shapes; this gate + * prevents an empty-resolve from being interpreted as a successful + * delivery (a common shape of caller bug). + * + * @private + * @param {unknown} receipt + * @returns {boolean} + */ + _validateReceipt(receipt) { + if (!receipt || typeof receipt !== 'object') return false; + const hasMsgId = typeof receipt.messageId === 'string' && receipt.messageId.length > 0; + const hasSessionId = typeof receipt.sessionId === 'string' && receipt.sessionId.length > 0; + return hasMsgId || hasSessionId; + } + + /** + * Race a Promise against a timeout. Returns the resolved value if the + * Promise wins, or `null` if the timeout fires first. Promise rejection + * is treated as "did not arrive" (same as timeout, returns `null`). + * + * @private + * @template T + * @param {Promise} promise + * @param {number} ms + * @returns {Promise} + */ + _raceObservedWithTimeout(promise, ms) { + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(null); + }, Math.max(0, ms)); + Promise.resolve(promise).then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }, + () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(null); + } + ); + }); + } + + /** + * Post-transport grace formula. Defaults to + * `min(remainingBudget, max(5000ms, timeoutMs * 0.1))`. Caller override + * is capped by remaining budget so it can never exceed the total timeout. + * + * @private + * @param {number | undefined} override + * @param {number} totalTimeoutMs + * @param {number} remainingMs + * @returns {number} + */ + _computeGrace(override, totalTimeoutMs, remainingMs) { + if (typeof override === 'number' && Number.isFinite(override) && override >= 0) { + return Math.min(override, remainingMs); + } + const defaultGrace = Math.max(5000, Math.floor(totalTimeoutMs * 0.1)); + return Math.min(defaultGrace, remainingMs); + } + + /** + * One-shot dev warning for low-level instant APIs. The warning is opt-in + * per call via `opts.expectsBackupPush === true`, and can be explicitly + * silenced via `opts.expectsBackupPush === false`. Fires at most once per + * ReiClient instance per method name. + * + * @private + * @param {string} methodName + * @param {{ expectsBackupPush?: boolean }} opts + */ + _maybeWarnLowLevel(methodName, opts) { + if (!opts || opts.expectsBackupPush !== true) return; + if (this._lowLevelWarned.has(methodName)) return; + this._lowLevelWarned.add(methodName); + const verdict = methodName === 'sendInstant' + ? 'HTTP 200 ≠ delivery confirmation' + : 'rejection ≠ delivery failure'; + console.warn( + `[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict. Pass expectsBackupPush: false to silence this warning.` + ); + } + // ─── Crypto helpers (Web Crypto API) ──────────────────────────── /** diff --git a/packages/rei-standard-amsg/client/test/deliver.test.mjs b/packages/rei-standard-amsg/client/test/deliver.test.mjs new file mode 100644 index 0000000..02eb7ef --- /dev/null +++ b/packages/rei-standard-amsg/client/test/deliver.test.mjs @@ -0,0 +1,1070 @@ +/** + * Tests for ReiClient.deliver() — the platform-agnostic delivery primitive. + * + * Covers RFC outcomes: delivered / completed-unconfirmed / timeout / cancelled + * / send-failed, plus receipt identity validation, pre-flight signal.aborted, + * post-transport grace, onChunk error capture, and discriminated-union input + * validation. fetch is stubbed per test; we build SSE Responses by hand on + * Node's WebStreams. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ReiClient } from '../src/index.js'; + +// ─── Test helpers ──────────────────────────────────────────────── + +const SSE_PAYLOAD_FRAME = (obj) => `event: payload\ndata: ${JSON.stringify(obj)}\n\n`; +const SSE_DONE_FRAME = `event: done\ndata: {}\n\n`; +const SSE_ERROR_FRAME = (obj) => + `event: error\ndata: ${JSON.stringify(obj)}\n\n`; + +/** + * Build a `Response` whose body is a `text/event-stream` ReadableStream + * fed by the provided frames in order. If `hangForever` is true the stream + * stays open until the caller's AbortSignal aborts (then the stream errors + * with AbortError, simulating a real-world iOS-killed fetch). + */ +function makeSseResponse({ frames = [], hangForever = false, signal, errorAfter = null } = {}) { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + if (signal) { + if (signal.aborted) { + try { controller.error(new DOMException('aborted', 'AbortError')); } catch {} + return; + } + signal.addEventListener('abort', () => { + try { controller.error(new DOMException('aborted', 'AbortError')); } catch {} + }, { once: true }); + } + (async () => { + for (const frame of frames) { + if (signal?.aborted) return; + try { controller.enqueue(encoder.encode(frame)); } + catch { return; } + } + if (errorAfter !== null) { + await new Promise(r => setTimeout(r, errorAfter)); + try { controller.error(new Error('stream upstream boom')); } + catch {} + return; + } + if (!hangForever) { + try { controller.close(); } catch {} + } + // else: stay open until abort + })(); + }, + cancel() { /* reader.cancel() from our consumer — fine */ }, + }); + + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +function makeJsonResponse(body, { status = 200 } = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** + * Install a fake global fetch backed by the given handler. Returns a + * restore function. The handler receives `(url, init)` and may return + * a Response, a Promise, or throw. + */ +function installFetch(handler) { + const original = globalThis.fetch; + globalThis.fetch = (url, init = {}) => { + return Promise.resolve().then(() => handler(url, init)); + }; + return () => { globalThis.fetch = original; }; +} + +function newClient(extra = {}) { + return new ReiClient({ + baseUrl: 'https://example.com', + instantEncryption: false, + ...extra, + }); +} + +// Deferred Promise utility ─────────────────────────────────────── +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +// ─── Input validation ──────────────────────────────────────────── + +test('deliver() throws if opts is missing', async () => { + const client = newClient(); + await assert.rejects(() => client.deliver({}, undefined), /requires an options object/); +}); + +test('deliver() throws if delivery is missing', async () => { + const client = newClient(); + await assert.rejects( + () => client.deliver({}, { timeoutMs: 100 }), + /requires opts\.delivery/ + ); +}); + +test('deliver() throws on unknown delivery.mode', async () => { + const client = newClient(); + await assert.rejects( + () => client.deliver({}, { delivery: { mode: 'magic' }, timeoutMs: 100 }), + /delivery\.mode must be "observed" or "transport-only"/ + ); +}); + +test('deliver() throws on observed mode without a Promise', async () => { + const client = newClient(); + await assert.rejects( + () => client.deliver({}, { + delivery: { mode: 'observed', observed: 'not-a-promise' }, + timeoutMs: 100, + }), + /observed must be a Promise/ + ); +}); + +test('deliver() throws on non-positive timeoutMs', async () => { + const client = newClient(); + await assert.rejects( + () => client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 0, + }), + /timeoutMs must be a positive finite number/ + ); +}); + +test('deliver() throws on negative postTransportGraceMs', async () => { + const client = newClient(); + await assert.rejects( + () => client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 100, + postTransportGraceMs: -1, + }), + /postTransportGraceMs/ + ); +}); + +// ─── Pre-flight aborted ────────────────────────────────────────── + +test('deliver() pre-flight: already-aborted signal returns cancelled without fetching', async () => { + const client = newClient(); + let fetchCalled = false; + const restore = installFetch(() => { fetchCalled = true; return makeSseResponse(); }); + + const ac = new AbortController(); + ac.abort(); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + signal: ac.signal, + }); + assert.equal(result.ok, false); + assert.equal(result.outcome, 'cancelled'); + assert.equal(result.detail.cancelledByCaller, true); + assert.equal(result.detail.waitedMs, 0); + assert.equal(fetchCalled, false, 'fetch must not be dispatched after pre-flight abort'); + } finally { restore(); } +}); + +// ─── outcome:'delivered' (observed mode) ───────────────────────── + +test('observed mode: receipt arrives before transport ends → delivered', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ + frames: [SSE_PAYLOAD_FRAME({ messageId: 'm1', content: 'hi' })], + hangForever: true, + signal: init.signal, + })); + + const observation = deferred(); + // resolve observation shortly + setTimeout(() => observation.resolve({ messageId: 'm1', channel: 'sw' }), 20); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + }); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'delivered'); + assert.deepEqual(result.detail.receipt, { messageId: 'm1', channel: 'sw' }); + } finally { restore(); } +}); + +test('observed mode: invalid receipt (no ids) does NOT trigger delivered — race continues to timeout', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + setTimeout(() => observation.resolve({ channel: 'sw' }), 10); // missing messageId AND sessionId + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.outcome, 'timeout'); + assert.equal(result.detail.receipt, undefined); + } finally { restore(); } +}); + +test('observed mode: receipt by sessionId only is also accepted', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + setTimeout(() => observation.resolve({ sessionId: 's42' }), 10); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + }); + assert.equal(result.outcome, 'delivered'); + assert.deepEqual(result.detail.receipt, { sessionId: 's42' }); + } finally { restore(); } +}); + +test('observed mode: observed Promise rejection does NOT trigger delivered — race continues', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + setTimeout(() => observation.reject(new Error('observation channel broke')), 10); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 200, + }); + assert.equal(result.outcome, 'timeout'); + } finally { restore(); } +}); + +// ─── outcome:'cancelled' (and late-receipt-after-cancel) ───────── + +test('cancelled outcome: caller aborts mid-stream and no late receipt within grace', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); // never resolves + + const ac = new AbortController(); + setTimeout(() => ac.abort(), 20); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + postTransportGraceMs: 30, // small cancel grace + signal: ac.signal, + }); + assert.equal(result.outcome, 'cancelled'); + assert.equal(result.detail.cancelledByCaller, true); + } finally { restore(); } +}); + +test('cancelled outcome: late receipt arrives within grace → delivered + cancelledByCaller', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + const ac = new AbortController(); + + setTimeout(() => ac.abort(), 20); + // late receipt lands during cancel grace + setTimeout(() => observation.resolve({ messageId: 'late-m' }), 40); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + postTransportGraceMs: 200, // half = 100ms grace window for late receipt + signal: ac.signal, + }); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'delivered'); + assert.equal(result.detail.cancelledByCaller, true); + assert.equal(result.detail.receipt.messageId, 'late-m'); + } finally { restore(); } +}); + +// ─── outcome:'timeout' (overall budget exhausted) ──────────────── + +test('timeout outcome: transport hangs, observation never fires', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: deferred().promise }, + timeoutMs: 80, + }); + assert.equal(result.outcome, 'timeout'); + assert.ok(result.detail.waitedMs >= 70); + } finally { restore(); } +}); + +// ─── outcome:'timeout' (observed mode + clean transport + no receipt) ── + +test('observed mode: transport ends clean, observation never fires → timeout + observationChannelStalled', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_PAYLOAD_FRAME({ messageId: 'm9' }), SSE_DONE_FRAME] })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: deferred().promise }, + timeoutMs: 500, + postTransportGraceMs: 40, + }); + assert.equal(result.outcome, 'timeout'); + assert.equal(result.detail.observationChannelStalled, true); + assert.equal(result.detail.transportEnded, true); + assert.equal(result.detail.transportError, undefined); + } finally { restore(); } +}); + +// ─── outcome:'send-failed' ────────────────────────────────────── + +test('send-failed: transport HTTP 500, no observation', async () => { + const client = newClient(); + const restore = installFetch(() => + new Response('boom', { status: 500, headers: { 'Content-Type': 'text/plain' } })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: deferred().promise }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.outcome, 'send-failed'); + assert.ok(result.detail.transportError); + assert.match(String(result.detail.transportError.message), /500/); + } finally { restore(); } +}); + +test('send-failed: SSE error frame, no observation', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_ERROR_FRAME({ code: 'UPSTREAM', message: 'llm 502' })] })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: deferred().promise }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.outcome, 'send-failed'); + assert.equal(result.detail.transportError.code, 'UPSTREAM'); + } finally { restore(); } +}); + +test('send-failed loses to delivered: SSE error frame BUT observed receipt arrived first', async () => { + // Even if transport errors, if observed delivered within grace we + // report 'delivered' — the message did land. + const client = newClient(); + const observation = deferred(); + + let resolveFetch; + const fetchGate = new Promise(r => { resolveFetch = r; }); + + const restore = installFetch(async () => { + await fetchGate; // hold transport + return makeSseResponse({ frames: [SSE_ERROR_FRAME({ code: 'BOOM', message: 'fail' })] }); + }); + + try { + setTimeout(() => observation.resolve({ messageId: 'm-fast' }), 20); + setTimeout(() => resolveFetch(), 40); // transport completes (with error) after observed + + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + }); + assert.equal(result.outcome, 'delivered'); + assert.equal(result.detail.receipt.messageId, 'm-fast'); + } finally { restore(); } +}); + +// ─── outcome:'completed-unconfirmed' (transport-only mode) ────── + +test('transport-only mode: clean SSE → completed-unconfirmed (no truth signal)', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_PAYLOAD_FRAME({ messageId: 'm9' }), SSE_DONE_FRAME] })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.ok, false); + assert.equal(result.outcome, 'completed-unconfirmed'); + assert.equal(result.detail.transportEnded, true); + assert.equal(result.detail.receipt, undefined); + } finally { restore(); } +}); + +test('transport-only mode: HTTP 500 → send-failed', async () => { + const client = newClient(); + const restore = installFetch(() => + new Response('boom', { status: 500, headers: { 'Content-Type': 'text/plain' } })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.outcome, 'send-failed'); + } finally { restore(); } +}); + +// ─── JSON transport branch ────────────────────────────────────── + +test('JSON transport: response body surfaced in detail.transportResponse', async () => { + const client = newClient(); + const restore = installFetch(() => + makeJsonResponse({ success: true, data: { messagesSent: 3, sentAt: '2026-06-08T00:00:00Z' } })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.outcome, 'completed-unconfirmed'); + assert.deepEqual(result.detail.transportResponse, { + success: true, + data: { messagesSent: 3, sentAt: '2026-06-08T00:00:00Z' }, + }); + } finally { restore(); } +}); + +test('JSON transport + observed mode: observation wins → delivered + JSON body preserved', async () => { + const client = newClient(); + let resolveFetch; + const gate = new Promise(r => { resolveFetch = r; }); + const restore = installFetch(async () => { + await gate; + return makeJsonResponse({ success: true, data: { messagesSent: 1 } }); + }); + + const observation = deferred(); + + try { + setTimeout(() => observation.resolve({ messageId: 'm-json' }), 20); + setTimeout(() => resolveFetch(), 40); + + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + }); + assert.equal(result.outcome, 'delivered'); + assert.equal(result.detail.receipt.messageId, 'm-json'); + } finally { restore(); } +}); + +// ─── onChunk error capture (does NOT promote outcome) ────────── + +test('onChunk throw is captured in detail.chunkHandlerError, outcome unaffected', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ + frames: [ + SSE_PAYLOAD_FRAME({ messageId: 'm1' }), + SSE_PAYLOAD_FRAME({ messageId: 'm2' }), + SSE_DONE_FRAME, + ], + })); + + const observation = deferred(); + setTimeout(() => observation.resolve({ messageId: 'm-receipt' }), 10); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + onChunk: () => { throw new Error('UI hook bug'); }, + }); + assert.equal(result.outcome, 'delivered'); + assert.ok(result.detail.chunkHandlerError, 'chunkHandlerError must be populated'); + assert.match(String(result.detail.chunkHandlerError.message), /UI hook bug/); + } finally { restore(); } +}); + +test('onChunk receives parsed payload objects', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ + frames: [ + SSE_PAYLOAD_FRAME({ messageId: 'm1', content: 'hi' }), + SSE_PAYLOAD_FRAME({ messageId: 'm2', content: 'there' }), + SSE_DONE_FRAME, + ], + })); + + const chunks = []; + try { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + onChunk: (p) => { chunks.push(p); }, + }); + assert.deepEqual(chunks, [ + { messageId: 'm1', content: 'hi' }, + { messageId: 'm2', content: 'there' }, + ]); + } finally { restore(); } +}); + +// ─── postTransportGraceMs default formula ────────────────────── + +test('default grace formula honors 5s floor: 30s timeout → grace ≈ 5000ms (10% < 5s floor)', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_DONE_FRAME] })); + + const observation = deferred(); + // Resolve receipt 4500ms after transport ends — within 5s floor grace + setTimeout(() => observation.resolve({ messageId: 'm-late' }), 4500); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 30_000, + }); + assert.equal(result.outcome, 'delivered'); + // Should arrive in ~4.5s window + assert.ok(result.detail.waitedMs < 6000, `waitedMs=${result.detail.waitedMs}`); + } finally { restore(); } +}); + +test('caller postTransportGraceMs is capped by remaining budget', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_DONE_FRAME] })); + + try { + // total budget 100ms, grace asked for 5000ms — should cap at remaining (~99ms) + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: deferred().promise }, + timeoutMs: 100, + postTransportGraceMs: 5000, + }); + assert.equal(result.outcome, 'timeout'); + // We should have returned well under 5000ms — the grace was capped. + assert.ok(result.detail.waitedMs < 500, `waitedMs=${result.detail.waitedMs}`); + } finally { restore(); } +}); + +// ─── Receipt identity edge cases ──────────────────────────────── + +test('receipt with empty-string messageId is rejected (must be non-empty)', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + setTimeout(() => observation.resolve({ messageId: '' }), 10); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 150, + }); + assert.equal(result.outcome, 'timeout'); + } finally { restore(); } +}); + +test('receipt that is not a plain object is rejected', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const observation = deferred(); + setTimeout(() => observation.resolve('m1'), 10); // string, not object + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 150, + }); + assert.equal(result.outcome, 'timeout'); + } finally { restore(); } +}); + +// ─── Low-level dev warning ────────────────────────────────────── + +test('sendInstant logs the low-level dev warning at most once when expectsBackupPush: true', async () => { + const client = newClient(); + const restore = installFetch(() => makeJsonResponse({ success: true })); + + const calls = []; + const origWarn = console.warn; + console.warn = (...args) => { calls.push(args.join(' ')); }; + + try { + await client.sendInstant({}, '/instant', { expectsBackupPush: true }); + await client.sendInstant({}, '/instant', { expectsBackupPush: true }); + assert.equal(calls.length, 1, 'warning should fire exactly once per instance'); + assert.match(calls[0], /sendInstant is a low-level/); + assert.match(calls[0], /HTTP 200/); + assert.match(calls[0], /deliver\(\)/); + } finally { + console.warn = origWarn; + restore(); + } +}); + +test('consumeInstantStream silenced when expectsBackupPush: false', async () => { + const client = newClient(); + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_DONE_FRAME] })); + + const calls = []; + const origWarn = console.warn; + console.warn = (...args) => { calls.push(args.join(' ')); }; + + try { + await client.consumeInstantStream({}, '/instant', { + onPayload: () => {}, + expectsBackupPush: false, + }); + assert.equal(calls.length, 0, 'expectsBackupPush:false must silence the warning'); + } finally { + console.warn = origWarn; + restore(); + } +}); + +test('low-level warning does not fire when expectsBackupPush is omitted', async () => { + // Per RFC: warn only on explicit opt-in (caller "自报"). + const client = newClient(); + const restore = installFetch(() => makeJsonResponse({ success: true })); + + const calls = []; + const origWarn = console.warn; + console.warn = (...args) => { calls.push(args.join(' ')); }; + + try { + await client.sendInstant({}, '/instant'); + assert.equal(calls.length, 0, 'no opt-in → no warning'); + } finally { + console.warn = origWarn; + restore(); + } +}); + +// ─── /simplify Phase 1 fixes — regression tests ───────────────── + +// SSE consumer must handle CRLF frame separators (.NET / IIS / some CDNs). +test('SSE: CRLF frame separators (\\r\\n\\r\\n) are parsed correctly', async () => { + const client = newClient(); + const CRLF_PAYLOAD = (obj) => `event: payload\r\ndata: ${JSON.stringify(obj)}\r\n\r\n`; + const CRLF_DONE = `event: done\r\ndata: {}\r\n\r\n`; + const restore = installFetch(() => + makeSseResponse({ frames: [CRLF_PAYLOAD({ messageId: 'm-crlf' }), CRLF_DONE] })); + + const chunks = []; + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + onChunk: (p) => { chunks.push(p); }, + }); + assert.equal(result.outcome, 'completed-unconfirmed'); + assert.deepEqual(chunks, [{ messageId: 'm-crlf' }]); + } finally { restore(); } +}); + +// Stream that ends without a trailing blank line must still flush the last frame. +test('SSE: trailing buffer without final blank-line is processed at EOF', async () => { + const client = newClient(); + const restore = installFetch(() => + // Note: the last frame has only ONE trailing \n — server closed early. + makeSseResponse({ frames: [`event: payload\ndata: ${JSON.stringify({ messageId: 'm-tail' })}\n`] })); + + const chunks = []; + try { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + onChunk: (p) => { chunks.push(p); }, + }); + assert.deepEqual(chunks, [{ messageId: 'm-tail' }]); + } finally { restore(); } +}); + +// Multibyte UTF-8 (CJK / emoji) split across chunk boundaries must not lose bytes. +test('SSE: UTF-8 multi-byte split across chunks survives EOF flush', async () => { + const client = newClient(); + // '楪' UTF-8 = E6 A5 AA. We'll feed the SSE bytes split mid-character. + const payload = JSON.stringify({ messageId: 'm-cjk', name: '楪同学' }); + const fullFrame = `event: payload\ndata: ${payload}\n\n`; + const fullBytes = new TextEncoder().encode(fullFrame); + // Split so the last chunk ends inside '楪同学' bytes. + const splitAt = fullBytes.length - 4; + const chunkA = fullBytes.slice(0, splitAt); + const chunkB = fullBytes.slice(splitAt); + + const restore = installFetch(() => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(chunkA); + controller.enqueue(chunkB); + controller.close(); + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })); + + const chunks = []; + try { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + onChunk: (p) => { chunks.push(p); }, + }); + assert.equal(chunks.length, 1); + assert.equal(chunks[0].name, '楪同学', 'multi-byte UTF-8 must round-trip'); + } finally { restore(); } +}); + +// Local validation errors (PAYLOAD_TOO_LARGE_LOCAL) must throw synchronously, +// not get buried inside outcome:'send-failed'. +test('deliver(): PAYLOAD_TOO_LARGE_LOCAL throws out instead of becoming send-failed', async () => { + const client = newClient({ maxPayloadBytes: 10 }); + const restore = installFetch(() => makeJsonResponse({ success: true })); + + try { + await assert.rejects( + () => client.deliver({ huge: 'x'.repeat(100) }, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + }), + (err) => err.code === 'PAYLOAD_TOO_LARGE_LOCAL' + ); + } finally { restore(); } +}); + +// transport-only mode + cancel must return promptly, not linger for cancel-grace. +test('cancelled outcome: transport-only mode does NOT linger waiting for observation', async () => { + const client = newClient(); + const restore = installFetch((_url, init) => + makeSseResponse({ frames: [], hangForever: true, signal: init.signal })); + + const ac = new AbortController(); + setTimeout(() => ac.abort(), 20); + + try { + const t0 = Date.now(); + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 60_000, + postTransportGraceMs: 5000, + signal: ac.signal, + }); + const elapsed = Date.now() - t0; + assert.equal(result.outcome, 'cancelled'); + // We should return well under the 2.5s cancel grace (5000/2), + // because transport-only has no observation channel to wait on. + assert.ok(elapsed < 500, `expected prompt return, took ${elapsed}ms`); + } finally { restore(); } +}); + +// deliver() must forward opts.authorization (parity with sendInstant). +test('deliver(): opts.authorization is sent as Authorization header', async () => { + const client = newClient(); + let seenAuthHeader = null; + const restore = installFetch((_url, init) => { + seenAuthHeader = init.headers?.['Authorization'] ?? init.headers?.['authorization'] ?? null; + return makeJsonResponse({ success: true }); + }); + + try { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + authorization: 'Bearer test-token-xyz', + }); + assert.equal(seenAuthHeader, 'Bearer test-token-xyz'); + } finally { restore(); } +}); + +// Content-Type dispatch must accept structured-suffix JSON variants. +test('deliver(): application/problem+json is accepted as a JSON response', async () => { + const client = newClient(); + const body = { type: 'about:blank', title: 'Bad Request' }; + const restore = installFetch(() => new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/problem+json' }, + })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + }); + assert.equal(result.outcome, 'completed-unconfirmed'); + assert.deepEqual(result.detail.transportResponse, body); + } finally { restore(); } +}); + +// Signal listener cleanup: a long-lived signal reused across many deliver() +// calls must not accumulate stale 'abort' listeners. +test('deliver(): caller signal listeners are removed on terminal outcome', async () => { + const client = newClient(); + const restore = installFetch(() => makeJsonResponse({ success: true })); + + const ac = new AbortController(); + let listenerCount = 0; + const realAdd = ac.signal.addEventListener.bind(ac.signal); + const realRemove = ac.signal.removeEventListener.bind(ac.signal); + ac.signal.addEventListener = (...args) => { listenerCount++; return realAdd(...args); }; + ac.signal.removeEventListener = (...args) => { listenerCount--; return realRemove(...args); }; + + try { + for (let i = 0; i < 10; i++) { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + postTransportGraceMs: 30, + signal: ac.signal, + }); + } + assert.equal(listenerCount, 0, `expected 0 net listeners after 10 calls, got ${listenerCount}`); + } finally { restore(); } +}); + +// The transport IIFE must not mutate detail.transportResponse after deliver() +// has already returned — observed mode wins → caller-held detail stays clean. +test('deliver(): late JSON transport response does NOT mutate already-returned detail', async () => { + const client = newClient(); + let resolveFetch; + const gate = new Promise(r => { resolveFetch = r; }); + const restore = installFetch(async () => { + await gate; + return makeJsonResponse({ success: true, data: { messagesSent: 7 } }); + }); + + const observation = deferred(); + + try { + setTimeout(() => observation.resolve({ messageId: 'm-fast' }), 20); + // Let the fetch fire well AFTER deliver() returns. + setTimeout(() => resolveFetch(), 100); + + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + }); + assert.equal(result.outcome, 'delivered'); + // Snapshot detail right after return. + const snapshot = { ...result.detail }; + + // Now wait long enough for the gated fetch to resolve. + await new Promise(r => setTimeout(r, 200)); + + // The detail object we received must NOT have grown a transportResponse + // field from the still-running IIFE. + assert.deepEqual(result.detail, snapshot, 'detail must not mutate post-return'); + assert.equal(result.detail.transportResponse, undefined); + } finally { restore(); } +}); + +// ─── Codex review fixes — regression tests ────────────────────── + +// transport-only post-transport grace must short-circuit (no observation +// channel to wait on after transport ends). +test('transport-only: post-transport grace does NOT linger after transport ends', async () => { + const client = newClient(); + const restore = installFetch(() => + makeJsonResponse({ success: true })); // immediate JSON response + + try { + const t0 = Date.now(); + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 60_000, // big timeout; default grace would be 5s + }); + const elapsed = Date.now() - t0; + assert.equal(result.outcome, 'completed-unconfirmed'); + // Default grace = max(5000, 6000) = 6000ms; we must NOT wait it out. + assert.ok(elapsed < 500, `expected prompt return, took ${elapsed}ms`); + } finally { restore(); } +}); + +// abort fired DURING async _buildInstantRequest must prevent the fetch. +test('deliver(): abort during async build prevents fetch dispatch', async () => { + const client = newClient(); + let fetchCalled = false; + const restore = installFetch(() => { + fetchCalled = true; + return makeJsonResponse({ success: true }); + }); + + const ac = new AbortController(); + // Fire abort in a microtask scheduled BEFORE _buildInstantRequest finishes. + // The encrypted/plaintext path is await-ed, so we have a microtask window. + Promise.resolve().then(() => ac.abort()); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + signal: ac.signal, + }); + assert.equal(result.outcome, 'cancelled'); + assert.equal(result.detail.cancelledByCaller, true); + assert.equal(fetchCalled, false, 'fetch must not be dispatched after mid-build abort'); + } finally { restore(); } +}); + +// abort fired DURING post-transport grace must surface as 'cancelled', +// not be silently downgraded to timeout / completed-unconfirmed. +test('deliver(): abort during post-transport grace returns cancelled', async () => { + const client = newClient(); + // Transport ends immediately with a clean SSE done frame. + const restore = installFetch(() => + makeSseResponse({ frames: [SSE_DONE_FRAME] })); + + const observation = deferred(); // never resolves + const ac = new AbortController(); + + try { + // Schedule abort to fire ~30ms after deliver() starts — well inside + // the default grace window for a 30s timeout (grace ≈ 5000ms). + setTimeout(() => ac.abort(), 30); + + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 30_000, + signal: ac.signal, + }); + assert.equal(result.outcome, 'cancelled'); + assert.equal(result.detail.cancelledByCaller, true); + } finally { restore(); } +}); + +// SSE CRLF split exactly on a chunk boundary: chunk1 ends with '\r', +// chunk2 starts with '\n'. The real line ending is '\r\n' (one terminator), +// must NOT be split into '\r' + '\n' which would falsely terminate the frame. +test('SSE: real CRLF split exactly across chunk boundary stays a single line ending', async () => { + const client = newClient(); + const payload = JSON.stringify({ messageId: 'm-seam' }); + const chunkA = new TextEncoder().encode(`event: payload\r`); + const chunkB = new TextEncoder().encode(`\ndata: ${payload}\r\n\r\n`); + + const restore = installFetch(() => new Response(new ReadableStream({ + async start(controller) { + controller.enqueue(chunkA); + // Force chunkB to arrive in a separate read. + await new Promise(r => setTimeout(r, 5)); + controller.enqueue(chunkB); + controller.close(); + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })); + + const chunks = []; + try { + await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + onChunk: (p) => { chunks.push(p); }, + }); + assert.deepEqual(chunks, [{ messageId: 'm-seam' }]); + } finally { restore(); } +}); + +// wrappedOnChunk catching a delayed throw must not mutate caller-held detail +// after deliver() has already returned via a different winner. +test('deliver(): late onChunk throw does NOT mutate already-returned detail.chunkHandlerError', async () => { + const client = newClient(); + // Hold the stream open with one payload frame, then never close — let + // observed win first; onChunk will throw AFTER deliver() returns. + let onChunkFired; + const onChunkGate = new Promise(r => { onChunkFired = r; }); + + const restore = installFetch((_url, init) => + makeSseResponse({ + frames: [SSE_PAYLOAD_FRAME({ messageId: 'm-chunk' })], + hangForever: true, + signal: init.signal, + })); + + const observation = deferred(); + setTimeout(() => observation.resolve({ messageId: 'm-fast' }), 10); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'observed', observed: observation.promise }, + timeoutMs: 5000, + onChunk: async () => { + // Park the onChunk call until after deliver() returns, then throw. + await new Promise(r => setTimeout(r, 80)); + onChunkFired(); + throw new Error('post-return onChunk bug'); + }, + }); + assert.equal(result.outcome, 'delivered'); + const snapshot = { ...result.detail }; + + // Wait for the deferred onChunk throw to land. + await onChunkGate; + await new Promise(r => setTimeout(r, 20)); + + assert.deepEqual(result.detail, snapshot, 'detail must not mutate post-return'); + assert.equal(result.detail.chunkHandlerError, undefined); + } finally { restore(); } +}); + +// Content-Type with parameters must be parsed as a media type, not +// substring-searched (so the parameter value can't trick the classifier). +test('Content-Type: parameter value containing media-type string is not mis-classified', async () => { + const client = newClient(); + // Body is JSON but a parameter mentions text/event-stream — naive substring + // would mis-classify as SSE. + const body = { success: true, data: { messagesSent: 1 } }; + const restore = installFetch(() => new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json; note=text/event-stream' }, + })); + + try { + const result = await client.deliver({}, { + delivery: { mode: 'transport-only' }, + timeoutMs: 500, + }); + assert.equal(result.outcome, 'completed-unconfirmed'); + assert.deepEqual(result.detail.transportResponse, body); + } finally { restore(); } +}); From 8682d231e84f9d2065aa81ea32469c6e6c54146c Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:41:41 +0800 Subject: [PATCH 3/7] fix(amsg-instant): defer SSE controller.close() until backup pushes settle (0.9.1-next.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE 模式下让 ReadableStream.start() 完整 await LLM 调用与所有 backup / fallback push 之后才 controller.close()。响应仍在产出期间 runtime 不会施加 wall-clock 上限, 慢 LLM + iOS Safari 后台杀 SSE socket 的组合下也能把这一轮消息送达。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 6 ++ packages/rei-standard-amsg/instant/README.md | 19 ++-- .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/instant/src/index.js | 43 +++++--- .../instant/test/handler.test.mjs | 100 +++++++++++++++++- 5 files changed, 143 insertions(+), 27 deletions(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 564e1ed..910f715 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — @rei-standard/amsg-instant +## 0.9.1-next.0 — SSE stream lifecycle owns LLM + push completion (pre-release) + +- **Fix**: SSE 模式下 LLM 调用与每条 payload 的 Web Push backup / fallback 完整运行在 `ReadableStream.start()` 内——`start()` 先 await 所有 backup 推送,再 `controller.close()`。响应仍在产出期间 runtime 不会施加 wall-clock 上限,慢 LLM + 客户端中途断开(iOS Safari 杀掉后台 SSE socket、页面切走等)的组合下也能把这一轮消息送达。 +- **Fix**: `event: error`(流内业务错误诊断)的 always-on backup push 现在确定性到达 push gateway,与其它 SSE payload 共用同一 `messageId`,由 `@rei-standard/amsg-sw` 的 dedupe gate 合并为单次通知。 +- **Docs**: README / JSDoc 校准 SSE 生命周期描述。SSE 模式由 stream 生命周期托管;`ctx.waitUntil` 在该模式下只做收尾兜底。纯 Web Push 模式(`Accept: application/json`)继续把主回复链路注册到 `waitUntil`。 + ## 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 作为正式环境推荐链路。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 3d87d62..e6a2add 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -83,8 +83,8 @@ createInstantHandler({ ### `createInstantHandler(options) → (request: Request, env?, ctx?) => Promise` 返回标准 Web Fetch API handler。直接挂到 Cloudflare Workers / Deno Deploy / Vercel Edge / Bun,或用下方四个 adapter 接到 Node / Netlify。 -如果运行时提供 `waitUntil`,可以通过请求 context 或 `options.waitUntil` 交给 handler, -主回复链路(LLM 生成 → 构造/切段 push payloads → 逐条 Web Push)会被注册进去。 +SSE 模式下 LLM + push 全程由 stream 生命周期托管,runtime 不会在响应仍在产出时回收 isolate; +纯 Web Push 模式下若运行时提供 `waitUntil`(请求 context 或 `options.waitUntil`),主回复链路会注册到它上面。 ```js import { createInstantHandler } from '@rei-standard/amsg-instant'; @@ -1025,16 +1025,17 @@ export default createCloudflareWorker((env) => ({ })); ``` -`createCloudflareWorker` 会接住 Workers 的第三个参数 `ctx`,并把主回复链路 -(LLM 生成 → 构造/切段 push payloads → 逐条 Web Push)注册到 `ctx.waitUntil`。这样浏览器关掉、 -页面切后台导致 HTTP 连接断开时,Worker 仍会尽力把这一轮推送跑完。直接把 +`createCloudflareWorker` 会接住 Workers 的第三个参数 `ctx`。直接把 `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 上限约束。 +SSE 默认模式下,handler 在 `ReadableStream.start()` 内部把 LLM 调用与每条 +payload 的 Web Push backup / fallback 全部驱动完,才关闭流——runtime 把这整段 +看作"响应仍在产出",不会施加 wall-clock 上限。即便客户端中途断开(页面切后台、 +iOS Safari 杀掉 SSE socket),剩余 LLM 输出与 fallback HTTP push 仍会跑完。 +`ctx.waitUntil` 在这里只是收尾兜底,不承载主回复链路;纯 Web Push 模式(`Accept: +application/json`)的主回复链路会注册到 `ctx.waitUntil`,仍受 runtime 的 +`waitUntil` / CPU / wall 上限约束。 ```toml # wrangler.toml diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 95dced6..532a866 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.9.0", + "version": "0.9.1-next.0", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 8a6ca98..b6c2603 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -155,8 +155,11 @@ const MIN_SSE_KEEPALIVE_MS = 250; * Vercel Edge, and Bun. Wrap it with one of the platform adapters if you * are on Node/Express, Netlify Functions, or Vercel Serverless Functions. * When used directly as a Cloudflare Workers module `fetch`, the extra - * `(env, ctx)` arguments are accepted so the main LLM → split → push - * pipeline can be protected by `ctx.waitUntil`. + * `(env, ctx)` arguments are accepted: SSE mode drives LLM and every + * push to completion inside the stream's `start()`, so the runtime + * never reclaims the isolate while work is in flight; pure-push mode + * (`Accept: application/json`) registers the LLM → split → push chain + * with `ctx.waitUntil`. * * @param {InstantHandlerOptions} options * @returns {(request: Request, envOrRuntime?: unknown, runtime?: unknown) => Promise} @@ -377,15 +380,21 @@ export function createInstantHandler(options) { // the consumer (no push-gateway rate limit to smooth over). processorCtx.spacingMs = 0; - // Lifecycle protection for the async tail of `start()`. After - // client disconnect the SSE controller stops emitting bytes, so - // runtimes like Cloudflare Workers lose their "request is alive" - // signal and may reclaim the isolate mid-fallback (the `await - // fetch(pushService)` inside `sendPushWithMaybeBlob`). Register - // a deferred that resolves only after `start()`'s `finally` runs - // — the runtime then keeps the isolate alive for the fallback - // push HTTP call to actually complete. Subject to the runtime / - // plan's own `waitUntil` and CPU/wall budget. + // Stream lifecycle. The runtime treats the Response body as + // "still in production" while `start()` is awaiting — no + // wall-clock limit applies. We use that window to drive LLM + + // every push to completion, then close last: + // - `streamUsable=false` on enqueue failure / abort makes + // subsequent payloads ship via Web Push fallback without + // returning early from `start()`. + // - `controller.close()` is called in `finally`, after + // `Promise.allSettled(backupWork)`, so push HTTP calls + // never race the runtime tearing down the isolate. + // `registerWaitUntil(startDone)` is a thin insurance for the + // window between `start()` finishing and the runtime releasing + // the isolate; it's resolved at the tail of `finally`, so under + // normal conditions there's nothing left for waitUntil to wait + // on. The LLM body never rides on it. let resolveStartDone; const startDone = new Promise((resolve) => { resolveStartDone = resolve; }); registerWaitUntil(startDone, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); @@ -515,7 +524,6 @@ export function createInstantHandler(options) { if (streamUsable) { try { controller.enqueue(SSE_DONE_BYTES); } catch { /* race with abort */ } - safeClose(); } } catch (err) { // HookError carries an in-loop ErrorPush that already @@ -523,9 +531,7 @@ export function createInstantHandler(options) { // 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 { + if (!(err instanceof HookError)) { const diag = buildErrorPush({ messageType: MESSAGE_TYPE.INSTANT, source: PUSH_SOURCE.INSTANT, @@ -538,11 +544,16 @@ export function createInstantHandler(options) { await safeEnqueue('error', diag, (pushErr) => { onEvent({ type: 'sse_error_fallback_failed', sessionId, cause: pushErr }); }); - safeClose(); } } finally { cleanup(); + // Close last: while `start()` is awaiting backupWork the + // runtime keeps the isolate alive without wall-clock + // pressure. Closing earlier would flip the request into + // invocation-end state and budget the remaining push + // HTTP fetches against the runtime's waitUntil ceiling. await Promise.allSettled(Array.from(backupWork)); + safeClose(); resolveStartDone(); } }, diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index a8f1739..7ca3c08 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -531,7 +531,15 @@ describe('createInstantHandler — happy path', () => { 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); + // The diagnostic `event: error` rides the same always-on backup + // push lane as every other SSE payload — same `messageId`, so the + // SW dedupes the SSE + push pair into a single notification. Wait + // for that backup push to land instead of relying on consumeSse + // returning before the fetch races in. + await waitForPushCalls(router, 1); + const backup = JSON.parse(await decryptCapturedPushBody(router.pushCalls[0].body, subKit)); + assert.equal(backup.messageId, errors[0].messageId); + assert.equal(backup.code, 'LLM_CALL_FAILED'); }); it('opt-out (Accept: application/json): returns PUSH_SEND_FAILED when push gateway returns non-2xx', async () => { @@ -707,6 +715,96 @@ describe('createInstantHandler — SSE backup push and stream lifecycle', () => assert.ok(events.some((event) => event.type === 'fallback_push_sent')); }); + it('defers controller.close() until backup pushes settle (no waitUntil 30s starvation)', async () => { + // Regression for the iOS-PWA backgrounded SSE failure: when + // `controller.close()` fired before backupWork awaited, the + // remaining push fetches got budgeted against waitUntil's 30s + // ceiling and slow LLM responses lost messages on CF Workers. + // The contract: stream EOF must NOT be observable until every + // backup push HTTP call has actually resolved. + let pushResolvedAt = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: async () => makeLlmResponse('deferred close path.'), + pushHandler: async () => { + await new Promise((r) => setTimeout(r, 120)); + pushResolvedAt = Date.now(); + return new Response(null, { status: 201 }); + }, + }); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const reader = res.body.getReader(); + // Drain past `event: done` to the real EOF — that's when + // `controller.close()` actually took effect. + while (true) { + const { done } = await reader.read(); + if (done) break; + } + const eofAt = Date.now(); + await waitUntilPromises[0]; + + assert.ok(pushResolvedAt > 0, 'backup push must have been invoked'); + assert.ok( + pushResolvedAt <= eofAt, + `expected backup push to resolve at-or-before stream EOF (push=${pushResolvedAt}, eof=${eofAt})`, + ); + }); + + it('fallback push on stream abort also settles before the worker releases', async () => { + // Same contract on the SSE-failure side: an aborted stream + // routes through `await fallback()` inline, but we want to + // observe the fallback fetch completing before the deferred + // `startDone` (= waitUntilPromises[0]) resolves. That's what + // protects the iOS-backgrounded user from a CF wall-clock kill. + let fallbackResolvedAt = 0; + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { + resolveLlm = () => resolve(makeLlmResponse('aborted slow-push path.')); + }), + pushHandler: async () => { + await new Promise((r) => setTimeout(r, 120)); + fallbackResolvedAt = Date.now(); + return new Response(null, { status: 201 }); + }, + }); + const waitUntilPromises = []; + const controller = new AbortController(); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + }); + 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]; + const releasedAt = Date.now(); + + assert.ok(fallbackResolvedAt > 0, 'fallback push must have been invoked'); + assert.ok( + fallbackResolvedAt <= releasedAt, + `expected fallback push to resolve before startDone (push=${fallbackResolvedAt}, released=${releasedAt})`, + ); + }); + it('immediateKeepalive enqueues a heartbeat as the first SSE chunk', async () => { let resolveLlm; const router = createFetchRouter({ From 07230cbb1457aea1b2ae0f654c28f0040dfe79db Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:16:43 +0800 Subject: [PATCH 4/7] test(amsg-instant): lock down "LLM never enters waitUntil" contract (0.9.1-next.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三条 SSE 生命周期回归断言: - 多 chunk + 中途 abort:每条 chunk 都通过 fallback push 投递,全部在 close 之前 - SSE 模式 waitUntil 注册数恒为 1(只剩 startDone deferred) - 慢 LLM 必须先于 controller.close() resolve 任何让 LLM 调用或 push HTTP 漂出 start() 的改动都会被拦下。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 4 + .../rei-standard-amsg/instant/package.json | 2 +- .../instant/test/handler.test.mjs | 114 ++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 910f715..a08fc5c 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-instant +## 0.9.1-next.1 — SSE lifecycle regression tests (pre-release) + +- **Tests**: 增加三条针对"LLM 永远不被塞进 waitUntil 30s 桶"契约的回归测试——多 chunk + 中途 abort 全 fallback 投递、SSE 模式 waitUntil 注册数恒为 1(只剩 startDone)、慢 LLM 必须先于 `controller.close()` resolve。任何让 LLM 调用或 push HTTP 漂出 `start()` 的改动都会被这套断言拦下。 + ## 0.9.1-next.0 — SSE stream lifecycle owns LLM + push completion (pre-release) - **Fix**: SSE 模式下 LLM 调用与每条 payload 的 Web Push backup / fallback 完整运行在 `ReadableStream.start()` 内——`start()` 先 await 所有 backup 推送,再 `controller.close()`。响应仍在产出期间 runtime 不会施加 wall-clock 上限,慢 LLM + 客户端中途断开(iOS Safari 杀掉后台 SSE socket、页面切走等)的组合下也能把这一轮消息送达。 diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 532a866..42aabbc 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.9.1-next.0", + "version": "0.9.1-next.1", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index 7ca3c08..2de41b1 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -805,6 +805,120 @@ describe('createInstantHandler — SSE backup push and stream lifecycle', () => ); }); + it('multi-chunk + mid-LLM abort: every chunk ships via fallback push before stream EOF', async () => { + // Regression guard for the worst-case iOS-PWA flow: + // - LLM produces N>1 sentences → N delivers + // - Client aborts the SSE socket while LLM is still running + // - Every chunk after the abort takes the inline `await fallback()` + // path; none should be dropped, all must land before EOF + // This protects against a regression where fallback push gets made + // fire-and-forget OR where the LLM loop bails on abort. + let resolveLlm; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { + resolveLlm = () => resolve(makeLlmResponse('第一句。第二句。第三句。第四句。')); + }), + }); + const events = []; + const waitUntilPromises = []; + const abortCtl = 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: abortCtl.signal, + }); + + const res = await handler(req); + assert.equal(res.status, 200); + abortCtl.abort(); + resolveLlm(); + await waitUntilPromises[0]; + + // Legacy splitter cuts on `[。!?!?]+` → 4 sentences, 4 delivers. + // All 4 must show up as fallback pushes (since SSE was aborted + // before any payload landed). + await waitForPushCalls(router, 4); + const fallbackSent = events.filter((e) => e.type === 'fallback_push_sent'); + assert.equal(fallbackSent.length, 4, `expected 4 fallback_push_sent events, got ${fallbackSent.length}`); + // No payload_enqueued events — stream was already aborted. + assert.equal(events.filter((e) => e.type === 'sse_payload_enqueued').length, 0); + }); + + it('SSE mode never registers the LLM/push pipeline with waitUntil (only the start() deferred)', async () => { + // Structural contract: waitUntil receives exactly ONE promise in + // SSE mode — the `startDone` deferred from registerWaitUntil at the + // top of the SSE branch. If a future change adds a second + // registerWaitUntil for the main reply chain (regressing toward the + // 30s-ceilinged failure mode), this test fails. + const router = llmRouter('contract check.'); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const { doneReceived } = await consumeSse(res); + assert.equal(doneReceived, true); + await waitUntilPromises[0]; + + assert.equal( + waitUntilPromises.length, + 1, + `SSE mode must register exactly one waitUntil promise (startDone). Got ${waitUntilPromises.length} — has the LLM/push pipeline regressed onto waitUntil?`, + ); + }); + + it('slow LLM resolves inside start() — controller.close() never fires before LLM completes', async () => { + // Regression guard for "LLM call moved out of start() and onto + // waitUntil". If that ever happens, a slow LLM would be killed at + // the runtime's waitUntil ceiling (~30s on Cloudflare Workers) and + // ship no messages on iOS-PWA backgrounded sessions. + // + // We assert the timing directly: LLM resolve timestamp must come + // strictly before stream EOF (i.e. `controller.close()`). + let llmResolvedAt = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => new Promise((resolve) => { + setTimeout(() => { + llmResolvedAt = Date.now(); + resolve(makeLlmResponse('slow llm content.')); + }, 150); + }), + }); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil: (work) => waitUntilPromises.push(work), + }); + + const res = await handler(makeRequest({ body: makeValidPayload() })); + const reader = res.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + const eofAt = Date.now(); + await waitUntilPromises[0]; + + assert.ok(llmResolvedAt > 0, 'LLM must have resolved'); + assert.ok( + llmResolvedAt < eofAt, + `LLM must resolve strictly before stream EOF (llm=${llmResolvedAt}, eof=${eofAt}). If they swapped, the LLM call has been moved out of start() onto waitUntil — DO NOT ship.`, + ); + }); + it('immediateKeepalive enqueues a heartbeat as the first SSE chunk', async () => { let resolveLlm; const router = createFetchRouter({ From 9cf18c338bf59fc61e372d00b426561e44b3d99d Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:00:19 +0800 Subject: [PATCH 5/7] fix(amsg): plug 4 latent bugs surfaced by /simplify pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sw: catch showNotification rejection so onNotificationSettled still runs; otherwise the dedupe record stays notificationStatePending:true forever and every backup duplicate is swallowed as 'first-delivery-pending'. - instant: replace strict `accept === 'application/json'` with acceptsJsonOnly() — tolerates `application/json; charset=utf-8` and `application/json, */*` so callers that ask for JSON actually get JSON instead of being silently routed into the SSE branch. - client: have sendInstant() set Accept: application/json. The default fetch Accept is `*/*` which would land on the SSE branch and break the subsequent res.json() with SyntaxError on the SSE bytes. - instant + server: strip // spans from messageContent before sentence-splitting when readReasoningContent's regex fallback matched. Previously the same private chain-of-thought shipped twice — once as a ReasoningPush, once as raw markup embedded inside the ContentPush burst. Cleanup folded in: - client: drop _urlBase64ToUint8Array (byte-for-byte duplicate of shared base64UrlToBytes) and hoist a module-level TEXT_ENCODER so _encrypt + _assertPayloadSize stop allocating one per call. - client: _maybeWarnLowLevel docstring + warn message reconciled with the actual code path — the previous "Pass expectsBackupPush: false to silence" claim was a lie because the default already silences. Tests: 403/403 across client / instant / server / shared / sw. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/client/src/index.js | 53 ++++++++++--------- .../rei-standard-amsg/instant/src/index.js | 18 ++++++- .../instant/src/message-processor.js | 32 +++++++++-- .../src/server/lib/message-processor.js | 30 +++++++++-- packages/rei-standard-amsg/sw/src/index.js | 14 +++-- 5 files changed, 111 insertions(+), 36 deletions(-) diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 0cb1d51..aef0ff4 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -28,6 +28,12 @@ * await client.scheduleMessage({ ... }); */ +import { base64UrlToBytes } from '@rei-standard/amsg-shared'; + +// `TextEncoder` is stateless — hoist once instead of allocating a fresh +// instance for every encrypt + payload-size check. +const TEXT_ENCODER = new TextEncoder(); + /** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */ /** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */ /** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */ @@ -398,11 +404,11 @@ export class ReiClient { * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'. * @param {{ authorization?: string, expectsBackupPush?: boolean }} [opts] * - `authorization`: optional auth header to forward. - * - `expectsBackupPush`: opt-in flag for the dev warning. Set to `true` - * to log a one-shot console.warn confirming you understand the - * "200 ≠ delivered" pitfall and have your own out-of-band check - * (or migrated to `deliver()`). Set to `false` to explicitly silence - * the warning (you have read this contract). + * - `expectsBackupPush`: opt-in dev reminder. Set to `true` to log a + * one-shot console.warn that this is a low-level transport and + * "HTTP 200 ≠ delivery confirmation" once the worker has backup + * push enabled (amsg-instant 0.9.0+ default). Default (omitted) is + * silent. * @returns {Promise} `{ success, data?: { messagesSent, sentAt }, error? }` */ async sendInstant(payload, endpointPath = '/instant', opts = {}) { @@ -413,6 +419,10 @@ export class ReiClient { endpointPath, { authorization: opts.authorization, methodName: 'sendInstant' } ); + // Pin the response shape: amsg-instant routes the JSON `{ success, data }` + // envelope only when the caller asked exclusively for it. Omitting Accept + // gets the SSE branch and `res.json()` then throws on the SSE bytes. + headers['Accept'] = 'application/json'; const res = await fetch(url, { method: 'POST', headers, body }); return res.json(); @@ -445,10 +455,10 @@ export class ReiClient { * @param {(error: unknown) => void} [options.onError] * @param {() => void} [options.onDone] * @param {AbortSignal} [options.signal] - * @param {boolean} [options.expectsBackupPush] - Opt-in flag for the dev warning. - * Set to `true` to log a one-shot console.warn confirming you understand the - * "rejection ≠ delivery failure" pitfall and have your own check (or migrated - * to `deliver()`). Set to `false` to explicitly silence the warning. + * @param {boolean} [options.expectsBackupPush] - Opt-in dev reminder. Set + * to `true` to log a one-shot console.warn that "rejection ≠ delivery + * failure" once the worker has backup push enabled (amsg-instant 0.9.0+ + * default). Default (omitted) is silent. * @returns {Promise} */ async consumeInstantStream(payload, endpointPath = '/instant', options = {}) { @@ -858,7 +868,7 @@ export class ReiClient { async subscribePush(vapidPublicKey, registration) { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey) + applicationServerKey: base64UrlToBytes(vapidPublicKey) }); return subscription; } @@ -909,7 +919,7 @@ export class ReiClient { */ _assertPayloadSize(bodyJson, methodName) { if (this._maxPayloadBytes == null) return; - const bytes = new TextEncoder().encode(bodyJson).length; + const bytes = TEXT_ENCODER.encode(bodyJson).length; if (bytes > this._maxPayloadBytes) { throw makeLocalError( 'PAYLOAD_TOO_LARGE_LOCAL', @@ -1202,10 +1212,10 @@ export class ReiClient { } /** - * One-shot dev warning for low-level instant APIs. The warning is opt-in - * per call via `opts.expectsBackupPush === true`, and can be explicitly - * silenced via `opts.expectsBackupPush === false`. Fires at most once per - * ReiClient instance per method name. + * One-shot dev reminder for low-level instant APIs. The warning is opt-in + * per call via `opts.expectsBackupPush === true` and fires at most once + * per ReiClient instance per method name. Default (omitted or `false`) + * is silent. * * @private * @param {string} methodName @@ -1219,7 +1229,7 @@ export class ReiClient { ? 'HTTP 200 ≠ delivery confirmation' : 'rejection ≠ delivery failure'; console.warn( - `[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict. Pass expectsBackupPush: false to silence this warning.` + `[rei-standard-amsg-client] ${methodName} is a low-level transport — ${verdict} when the worker is configured with always-on backup Web Push (amsg-instant 0.9.0+ default). Prefer client.deliver() for a correct delivered / cancelled / timeout / send-failed verdict.` ); } @@ -1236,7 +1246,7 @@ export class ReiClient { const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']); - const encoded = new TextEncoder().encode(plaintext); + const encoded = TEXT_ENCODER.encode(plaintext); const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded); // Web Crypto appends the 16-byte auth tag at the end of the ciphertext @@ -1300,15 +1310,6 @@ export class ReiClient { return arr; } - /** @private */ - _urlBase64ToUint8Array(base64String) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const raw = atob(base64); - const arr = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); - return arr; - } } function normalizeMaxPayloadBytes(value) { diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index b6c2603..3077df3 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -51,6 +51,22 @@ const SSE_DONE_BYTES = SSE_ENCODER.encode('event: done\ndata: {}\n\n'); const DEFAULT_SSE_KEEPALIVE_MS = 1000; const MIN_SSE_KEEPALIVE_MS = 250; +/** + * True when the caller asked exclusively for JSON. Any Accept value + * that lists another media type (including `*\/*`) keeps the default + * SSE transport — that's the legacy `consumeInstantStream` path which + * does not set an Accept header at all. + * + * Strict `headers.get('accept') === 'application/json'` is too narrow: + * `application/json; charset=utf-8` or `application/json, *\/*` would + * silently fall through to SSE and break `await res.json()` callers. + */ +function acceptsJsonOnly(acceptHeader) { + if (typeof acceptHeader !== 'string' || acceptHeader.length === 0) return false; + const ranges = acceptHeader.split(',').map((r) => r.split(';')[0].trim().toLowerCase()).filter(Boolean); + return ranges.length > 0 && ranges.every((r) => r === 'application/json'); +} + /** * @typedef {Object} VapidConfig * @property {string} email @@ -324,7 +340,7 @@ export function createInstantHandler(options) { } try { - const isPurePush = request.headers.get('accept') === 'application/json'; + const isPurePush = acceptsJsonOnly(request.headers.get('accept')); const sessionId = typeof payload.sessionId === 'string' && payload.sessionId ? payload.sessionId : `sess_${randomUUID()}`; const processorCtx = { diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index c26a4de..4ed7952 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -320,7 +320,7 @@ function readReasoningContent(llmResponse) { const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices; if (!Array.isArray(choices) || choices.length === 0) return null; const message = /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */ (choices[0])?.message; - + const raw = message?.reasoning_content; if (typeof raw === 'string') { const trimmed = raw.trim(); @@ -329,7 +329,7 @@ function readReasoningContent(llmResponse) { const content = message?.content; if (typeof content === 'string') { - const match = content.match(/<(think|thinking|thought)>([\s\S]*?)<\/\1>/i); + const match = content.match(REASONING_TAG_RE); if (match) { const trimmed = match[2].trim(); if (trimmed.length > 0) return trimmed; @@ -339,6 +339,28 @@ function readReasoningContent(llmResponse) { return null; } +/** + * Matches `` / `` / `` + * spans (case-insensitive, lazy multi-line). Mirrored in + * `amsg-server/src/server/lib/message-processor.js` — keep in lockstep. + */ +const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i; +const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi; + +/** + * Drop any `` / `` / `` spans from a user-facing + * content string. Used after `readReasoningContent` matched the regex + * fallback path so the same private chain-of-thought does not also ship + * inside the ContentPush burst. + * + * @param {string} content + * @returns {string} + */ +function stripReasoningTags(content) { + if (typeof content !== 'string' || !content.includes('<')) return content; + return content.replace(REASONING_TAG_RE_G, '').trim(); +} + /** * Process one instant request. Dispatches between two **independent** * paths based on whether the caller provided an `onLLMOutput` hook: @@ -433,6 +455,10 @@ async function runLegacyInstant(payload, ctx) { // the actual reply. const reasoning = readReasoningContent(llmResponse); if (reasoning) { + // When reasoning came from the `` fallback in message.content, + // the same span is still embedded in messageContent — strip it before + // the sentence-split below so raw markup never ships inside ContentPush. + messageContent = stripReasoningTags(messageContent); const reasoningPush = buildReasoningPush({ messageType: MESSAGE_TYPE.INSTANT, source: PUSH_SOURCE.INSTANT, @@ -1029,4 +1055,4 @@ function buildBlobUrl(requestUrl, key) { return `/blob/${key}`; } -export { sendPushWithMaybeBlob, readReasoningContent, ensureStableMessageId }; +export { sendPushWithMaybeBlob, readReasoningContent, stripReasoningTags, ensureStableMessageId }; diff --git a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js index 02c1402..2765c38 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js +++ b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js @@ -89,7 +89,7 @@ function readReasoningContent(llmResponse) { const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices; if (!Array.isArray(choices) || choices.length === 0) return null; const message = /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */ (choices[0])?.message; - + const raw = message?.reasoning_content; if (typeof raw === 'string') { const trimmed = raw.trim(); @@ -98,7 +98,7 @@ function readReasoningContent(llmResponse) { const content = message?.content; if (typeof content === 'string') { - const match = content.match(/<(think|thinking|thought)>([\s\S]*?)<\/\1>/i); + const match = content.match(REASONING_TAG_RE); if (match) { const trimmed = match[2].trim(); if (trimmed.length > 0) return trimmed; @@ -108,6 +108,22 @@ function readReasoningContent(llmResponse) { return null; } +const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i; +const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi; + +/** + * Mirrors `amsg-instant/src/message-processor.js#stripReasoningTags`. + * Removes any private chain-of-thought markup leaking through + * `message.content` so it does not also ship inside ContentPush. + * + * @param {string} content + * @returns {string} + */ +function stripReasoningTags(content) { + if (typeof content !== 'string' || !content.includes('<')) return content; + return content.replace(REASONING_TAG_RE_G, '').trim(); +} + /** * @typedef {Object} ProcessorContext * @property {Object} webpush - The web-push module instance (already VAPID-configured). @@ -161,6 +177,15 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { throw new Error('Invalid message configuration: no content source available'); } + // Auto-extract reasoning BEFORE the sentence split: when reasoning + // came from the `` fallback inside message.content, the same + // span is still embedded in messageContent and would otherwise leak + // as raw markup into ContentPush. + const reasoning = readReasoningContent(llmResponse); + if (reasoning) { + messageContent = stripReasoningTags(messageContent); + } + // Sentence splitting (mirrors @rei-standard/amsg-instant // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may // override the default regex via decryptedPayload.splitPattern (string @@ -197,7 +222,6 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { // LLM response carried non-empty reasoning_content. `fixed` and // explicit-userMessage paths produce no LLM response, so this // block is naturally skipped for them (llmResponse stays null). - const reasoning = readReasoningContent(llmResponse); if (reasoning) { const reasoningPush = buildReasoningPush({ messageType: decryptedPayload.messageType, diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 435640c..71f92e0 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -303,11 +303,19 @@ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSett if (notificationState.shouldRender) { const notification = createNotificationFromPayload(payload, defaults); if (notification) { + // A rejected showNotification (permission revoked / quota / OS error) + // must NOT stop onNotificationSettled from running — that callback is + // the only thing that clears `notificationStatePending`, and leaving + // it stuck makes the backup transport's repair path swallow every + // duplicate as 'first-delivery-pending'. notificationWork.push( sw.registration.showNotification(notification.title, notification.options) - .then(() => { - notificationState.shown = true; - }) + .then( + () => { notificationState.shown = true; }, + (error) => { + console.error('[rei-standard-amsg-sw] showNotification rejected:', error); + } + ) ); } } From 35e4542ee27256ac775880545ec3d911469ebe86 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:05:54 +0800 Subject: [PATCH 6/7] chore(release): bump amsg packages to client 2.5.0 / instant 0.9.1 / server 2.5.1 / sw 2.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - amsg-client 2.5.0-next.0 → 2.5.0: graduate `deliver()` primitive to latest dist-tag; fold in Accept-header pin on sendInstant() and three internal cleanups. - amsg-instant 0.9.1-next.1 → 0.9.1: graduate stream-lifecycle work; add tolerant Accept parsing + -tag strip from ContentPush. - amsg-server 2.5.0 → 2.5.1: mirror the -tag strip so scheduled pushes do not leak chain-of-thought into the user-facing burst. - amsg-sw 2.3.0 → 2.3.1: catch showNotification rejections so the dedupe record cannot stall at notificationStatePending:true. Co-Authored-By: Claude Opus 4.7 (1M context) --- bump.mjs | 8 ++++---- packages/rei-standard-amsg/client/CHANGELOG.md | 13 ++++++++++--- packages/rei-standard-amsg/client/package.json | 2 +- packages/rei-standard-amsg/instant/CHANGELOG.md | 9 ++++----- packages/rei-standard-amsg/instant/package.json | 2 +- packages/rei-standard-amsg/server/CHANGELOG.md | 4 ++++ packages/rei-standard-amsg/server/package.json | 2 +- packages/rei-standard-amsg/sw/CHANGELOG.md | 4 ++++ packages/rei-standard-amsg/sw/package.json | 2 +- 9 files changed, 30 insertions(+), 16 deletions(-) diff --git a/bump.mjs b/bump.mjs index 360b692..9ee0b9d 100644 --- a/bump.mjs +++ b/bump.mjs @@ -12,7 +12,7 @@ function updatePkg(pkgPath, version, sharedDep) { } updatePkg('packages/rei-standard-amsg/shared/package.json', '0.2.0', null); -updatePkg('packages/rei-standard-amsg/sw/package.json', '2.3.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'); +updatePkg('packages/rei-standard-amsg/sw/package.json', '2.3.1', '0.2.0'); +updatePkg('packages/rei-standard-amsg/instant/package.json', '0.9.1', '0.2.0'); +updatePkg('packages/rei-standard-amsg/client/package.json', '2.5.0', '0.2.0'); +updatePkg('packages/rei-standard-amsg/server/package.json', '2.5.1', '0.2.0'); diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index dd1fb91..82604fb 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog — @rei-standard/amsg-client -## 2.5.0-next.0 — `deliver()` 平台无关送达 primitive (pre-release) - -发布在 `next` dist-tag。供 SullyOS 等真实接入方端到端验证 deliver() 在 iOS PWA / SW 双通道实战下的行为;本地 55 条单测全过但 SSE 流式 / Web Push backup / iOS 后台杀 fetch 等场景没法在 Node 里仿真,所以先 next 再升 latest。验收 OK 后 graduate 到 `2.5.0`(`npm dist-tag add` 或重发正式版)。 +## 2.5.0 — `deliver()` 平台无关送达 primitive 把"发出去"和"业务上是否真送达"在 API 层显式分开。新增 `client.deliver()` 作为新代码的首选入口;老的 `sendInstant()` / `consumeInstantStream()` 仍可用但降级为低级 transport,配 opt-in dev warning 引导迁移。SSE 与 JSON 两条 transport 一并升级到统一的送达协调层,调用方无需感知。 +`2.5.0-next.0` 先发在 `next` dist-tag 跑了一轮 SullyOS 等接入方的端到端验证(iOS PWA / SW 双通道实战),无回归后 graduate 到 `latest`。 + ### New - 新增 `client.deliver(payload, opts)`:单一入口,根据响应 `Content-Type` 自动选 SSE 或 JSON transport,与 caller 提供的「观察通道 `Promise`」做 race + grace,返回 `DeliveryResult` 含五值 `outcome`(`delivered` / `cancelled` / `timeout` / `send-failed` / `completed-unconfirmed`)。 @@ -76,6 +76,13 @@ Self-review 时(仿 ultrareview 多角度分派)抓到的 correctness 修复 测试集相应扩到 55 条,覆盖以上每个修复 + transport-only 短路 + 跨 chunk seam 的真 CRLF 场景;之前自己写的 5 条直接动 `globalThis.fetch` 的测试也改成走 `installFetch()` restore 模式,避免污染更大 suite。 +### 正式版补丁(折叠进 2.5.0) + +- **`sendInstant()` 显式带 `Accept: application/json`**:默认 `Accept: */*` 会落到 amsg-instant 的 SSE 分支,随后的 `res.json()` 在 SSE 字节流上抛 SyntaxError。`sendInstant()` 是声明回 JSON 的入口,header 一并钉死。 +- **`expectsBackupPush` 文档与代码对齐**:JSDoc 与 warn 文案此前宣称 "Pass `expectsBackupPush: false` to silence",实际 `false`、不传都是静默,`true` 才会触发一次性 warn。文案改成 opt-in dev reminder,默认静默,不再误导调用方。 +- **去掉 `_urlBase64ToUint8Array`**:与 `@rei-standard/amsg-shared` 的 `base64UrlToBytes` 逐字节重复(已有 `atob` + Node `Buffer` 双兜底),改 import shared 版本。 +- **模块级 `TEXT_ENCODER`**:`_encrypt` 与 `_assertPayloadSize` 此前每次都 `new TextEncoder()`。`TextEncoder` 是无状态的,提到 module top 复用,跟 instant / sw 对齐。 + ## 2.4.0 — `consumeInstantStream()` SSE consumer 配套 `@rei-standard/amsg-instant@0.9.0` 的 SSE 默认模式;同时移除 client 默认请求体大小上限,避免本地误拦长上下文请求。 diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index 7493054..b9ab75e 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.5.0-next.0", + "version": "2.5.0", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index a08fc5c..99b7f59 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,13 +1,12 @@ # Changelog — @rei-standard/amsg-instant -## 0.9.1-next.1 — SSE lifecycle regression tests (pre-release) - -- **Tests**: 增加三条针对"LLM 永远不被塞进 waitUntil 30s 桶"契约的回归测试——多 chunk + 中途 abort 全 fallback 投递、SSE 模式 waitUntil 注册数恒为 1(只剩 startDone)、慢 LLM 必须先于 `controller.close()` resolve。任何让 LLM 调用或 push HTTP 漂出 `start()` 的改动都会被这套断言拦下。 - -## 0.9.1-next.0 — SSE stream lifecycle owns LLM + push completion (pre-release) +## 0.9.1 — SSE stream lifecycle owns LLM + push completion - **Fix**: SSE 模式下 LLM 调用与每条 payload 的 Web Push backup / fallback 完整运行在 `ReadableStream.start()` 内——`start()` 先 await 所有 backup 推送,再 `controller.close()`。响应仍在产出期间 runtime 不会施加 wall-clock 上限,慢 LLM + 客户端中途断开(iOS Safari 杀掉后台 SSE socket、页面切走等)的组合下也能把这一轮消息送达。 - **Fix**: `event: error`(流内业务错误诊断)的 always-on backup push 现在确定性到达 push gateway,与其它 SSE payload 共用同一 `messageId`,由 `@rei-standard/amsg-sw` 的 dedupe gate 合并为单次通知。 +- **Fix**: `isPurePush` 不再用严格相等比对 Accept header。原 `headers.get('accept') === 'application/json'` 把 `application/json; charset=utf-8` / `application/json, */*` 这类合规变种全部错路由到 SSE 分支;改成 `acceptsJsonOnly()`——只在所有 media range 都是 `application/json` 时返回纯 push 分支,让声明要 JSON 的调用方真的拿到 JSON。 +- **Fix**: `readReasoningContent` 命中 `` / `` / `` fallback 时,原本同一段内容会被推两次——一次 ReasoningPush,一次仍带 raw 标签嵌在 ContentPush。新增 `stripReasoningTags()` 在 sentence-split 之前剥掉这些 span,私有 chain-of-thought 不再泄到正文。 +- **Tests**: 增加三条针对"LLM 永远不被塞进 waitUntil 30s 桶"契约的回归测试——多 chunk + 中途 abort 全 fallback 投递、SSE 模式 waitUntil 注册数恒为 1(只剩 startDone)、慢 LLM 必须先于 `controller.close()` resolve。任何让 LLM 调用或 push HTTP 漂出 `start()` 的改动都会被这套断言拦下。 - **Docs**: README / JSDoc 校准 SSE 生命周期描述。SSE 模式由 stream 生命周期托管;`ctx.waitUntil` 在该模式下只做收尾兜底。纯 Web Push 模式(`Accept: application/json`)继续把主回复链路注册到 `waitUntil`。 ## 0.9.0 — always-on SSE backup push + keepalive controls diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 42aabbc..106c782 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.9.1-next.1", + "version": "0.9.1", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index 9a26205..9bd1f37 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-server +## 2.5.1 — `` 不再泄进 ContentPush + +- **Fix**: `readReasoningContent` 走 `` / `` / `` fallback 抽出 reasoning 后,`splitMessageIntoSentences` 拿到的还是原始字符串,私有 chain-of-thought 被同步当成 ContentPush 推送给用户。新增 `stripReasoningTags()` 并把 reasoning 抽取重排到 sentence-split 之前——命中 fallback 时把同一段从 `messageContent` 里剥掉再切句,与 `@rei-standard/amsg-instant` 0.9.1 保持镜像同步。 + ## 2.5.0 — Dependency bump - 依赖更新:同步升级 `@rei-standard/amsg-shared` 至稳定版 `0.2.0`,让正式发版环境不解析出混版本 shared graph。 diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 85b5dca..8092c19 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-server", - "version": "2.5.0", + "version": "2.5.1", "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", diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index e0d4747..2bf9297 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-sw +## 2.3.1 — `showNotification` 拒绝不再卡死 dedupe 状态 + +- **Fix**: `dispatchBusinessPayload` 给 `sw.registration.showNotification(...)` 加了 `.catch(...)` 兜底。原链路只挂了成功分支 `.then(() => notificationState.shown = true)`,当浏览器拒绝展示(权限被撤、quota / OS 限制等)时整个 `Promise.all(notificationWork)` 会 reject,`onNotificationSettled` 被跳过,dedupe 记录永远停在 `notificationStatePending: true`。后续同 key 的 backup transport 重复会被 `maybeShowDuplicateNotification` 当成 `first-delivery-pending` 吞掉,用户彻底看不到通知。现在拒绝只记录到 `console.error`,`notificationState.shown` 保持 false,但 `onNotificationSettled` 一定执行,dedupe 状态正常推进。 + ## 2.3.0 — IndexedDB 连接韧性 + 业务感知的 DELIVER ack - **Fix**: IndexedDB 连接被浏览器**强制关闭**(backing store 出错 / 存储压力 / 清数据)后自愈。强关只触发 `close`、不触发 `versionchange`,此前缓存里的死连接会被无限复用,每次事务都抛 `InvalidStateError`,导致去重失灵、push 落库被阻断、`dedupe cleanup failed` 刷屏且不重启 SW 不恢复。dedupe 库与 queue / multipart 库(`cachedDB`)一并修复。 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index 7ab710e..b03c0c6 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.3.0", + "version": "2.3.1", "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", From 005d92464205da2fcee1d2372253eb71dbaf8c1e Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:58:08 +0800 Subject: [PATCH 7/7] docs(amsg-server): de-deprecate in-server instant path (2.5.2) The in-server instant path (messageType: 'instant', create task -> process by UUID -> delete task) is now a first-class supported path again. Removed the @deprecated JSDoc and the steering toward amsg-instant; relabeled the 'legacy in-server instant' comments to neutral wording. JSDoc/README now describe both instant paths by their own characteristics: the DB-backed in-server path for long-running or zero-loss generations, and the stateless amsg-instant package for short messages on edge runtimes. Runtime behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 8 ++-- .../rei-standard-amsg/server/CHANGELOG.md | 7 ++++ packages/rei-standard-amsg/server/README.md | 4 +- .../rei-standard-amsg/server/package.json | 2 +- .../src/server/handlers/schedule-message.js | 39 +++++++++++++------ .../src/server/lib/message-processor.js | 8 ++-- 6 files changed, 47 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34bf24d..fdc949f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,7 +1850,7 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.2.0" @@ -1865,7 +1865,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.2.0" @@ -1880,7 +1880,7 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.5.0", + "version": "2.5.2", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", @@ -1923,7 +1923,7 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.3.0", + "version": "2.3.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.2.0" diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index 9bd1f37..27ebfe0 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog — @rei-standard/amsg-server +## 2.5.2 — in-server instant 路径恢复为一等公民 + +- **文档**:移除 `schedule-message` 中 `messageType: 'instant'` 两处 JSDoc 的 `@deprecated Soft-deprecated` 标记;该路径(create task → process by UUID → delete task)现以正式支持路径身份记录,不再携带弃用暗示。 +- **注释**:`message-processor` 模块头及行内注释中的 "legacy in-server instant" 措辞统一改为 "in-server instant path"(中性术语)。 +- **选型说明**:JSDoc 与 README 补充两条 instant 路径各自的适用场景——本端点的 DB 路径任务落库后投递不绑连接生命周期,适合长时间生成 / 零丢失;`@rei-standard/amsg-instant` 无状态、纯 SSE + Web Push,适合能在断连宽限期内(Deno Deploy 实测 ≈20-30s)跑完的短任务。不再有"新代码请改用"的导流建议。 +- 运行时行为不变,无 breaking change。 + ## 2.5.1 — `` 不再泄进 ContentPush - **Fix**: `readReasoningContent` 走 `` / `` / `` fallback 抽出 reasoning 后,`splitMessageIntoSentences` 拿到的还是原始字符串,私有 chain-of-thought 被同步当成 ContentPush 推送给用户。新增 `stripReasoningTags()` 并把 reasoning 抽取重排到 sentence-split 之前——命中 fallback 时把同一段从 `messageContent` 里剥掉再切句,与 `@rei-standard/amsg-instant` 0.9.1 保持镜像同步。 diff --git a/packages/rei-standard-amsg/server/README.md b/packages/rei-standard-amsg/server/README.md index 14dd0aa..1a698d1 100644 --- a/packages/rei-standard-amsg/server/README.md +++ b/packages/rei-standard-amsg/server/README.md @@ -54,7 +54,9 @@ const rei = await createReiServer({ ## 关于 `messageType: 'instant'` -> **Note**:新代码的 instant 消息请用 [@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md),跳过本端点的"建任务 → 处理 → 删任务" DB 来回。本端点的 `instant` 分支为兼容保留,行为不变、不会有运行时警告。 +> **两条 instant 路径,按各自特点选一条(都是正式支持路径):** +> - **本端点的 `messageType: 'instant'`**(create task → process by UUID → delete task):任务先写进数据库再处理,投递不绑在请求连接上——客户端断开也没关系,任务行还在,能继续跑、能重试,想跑多久跑多久。适合**有数据库、需要长时间生成或保证消息零丢失**的场景。 +> - **[@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)**:纯 SSE 流 + Web Push backup,不需要数据库,适合无状态边缘运行时(如 Cloudflare Workers)。它的处理挂在响应连接上,客户端一断开就只剩平台给的那点宽限期把活干完(Deno Deploy 实测 ≈20-30s),所以适合**能快速跑完的短即时消息**。 ## AI 接口 `apiUrl` 约束 diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 8092c19..5ad66d9 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-server", - "version": "2.5.1", + "version": "2.5.2", "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", diff --git a/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js index 95211e4..4a5d632 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/schedule-message.js @@ -107,12 +107,20 @@ export function createScheduleMessageHandler(ctx) { const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey); /** - * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant. - * This branch is kept for backward compatibility and the existing behavior - * (create task → process → delete) is unchanged. The dedicated amsg-instant - * package is stateless (no DB roundtrip), deployable to Cloudflare Workers, - * and locks the encryption + push-payload contract behind a single version. - * See packages/rei-standard-amsg/instant/README.md. + * In-server instant path. Delivers an instant message through this + * server's own task queue (create task → process by UUID → delete task). + * The task is written to the database before processing, so delivery is + * not tied to the request connection: even if the client disconnects, the + * row stays and the generation keeps running (and can be retried) for as + * long as it needs. Use this when you have a database and want long or + * guaranteed-complete generations with no dropped messages. + * + * The stateless alternative is `@rei-standard/amsg-instant`: it streams + * over SSE with a Web Push backup and needs no database, which makes it a + * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the + * response connection, so after the client disconnects it only has the + * platform's brief grace window to finish (≈20-30s observed on Deno + * Deploy) — ideal for short instant messages that complete quickly. */ // Instant type: check VAPID before creating the task to avoid orphaned rows if (payload.messageType === 'instant') { @@ -168,11 +176,20 @@ export function createScheduleMessageHandler(ctx) { } /** - * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant. - * The "create-task → process-by-uuid → delete-task" sequence below is - * preserved verbatim so existing clients keep working. New integrations - * should call the dedicated amsg-instant endpoint instead — it skips this - * DB round-trip entirely. See packages/rei-standard-amsg/instant/README.md. + * In-server instant path. Delivers an instant message through this + * server's own task queue (create task → process by UUID → delete task). + * The task is written to the database before processing, so delivery is + * not tied to the request connection: even if the client disconnects, the + * row stays and the generation keeps running (and can be retried) for as + * long as it needs. Use this when you have a database and want long or + * guaranteed-complete generations with no dropped messages. + * + * The stateless alternative is `@rei-standard/amsg-instant`: it streams + * over SSE with a Web Push backup and needs no database, which makes it a + * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the + * response connection, so after the client disconnects it only has the + * platform's brief grace window to finish (≈20-30s observed on Deno + * Deploy) — ideal for short instant messages that complete quickly. */ // Instant type: send immediately if (payload.messageType === 'instant') { diff --git a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js index 2765c38..3d01b4d 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js +++ b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js @@ -3,13 +3,13 @@ * ReiStandard amsg-server v2.4.0 * * Handles single message content generation and Web Push delivery for - * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy - * via-server instant path (`messageType: 'instant'`). + * scheduled tasks (`fixed` / `prompted` / `auto`) and the + * in-server instant path (`messageType: 'instant'`). * * Push wire shape comes from `@rei-standard/amsg-shared`'s * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`) * routes on `messageKind`. Server-driven pushes always carry - * `source: 'instant'` (for the legacy in-server instant) or + * `source: 'instant'` (for the in-server instant path) or * `source: 'scheduled'` (for everything else). * * v2.4.0: when the LLM response carries non-empty @@ -213,7 +213,7 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { // `messageId` format — deterministic when we have a task.id so a // retry produces the same id for the same (task, sentence) pair // (downstream dedupers can key on it). Falls back to a UUID for - // the legacy in-server instant path that has no row id. + // the in-server instant path that has no row id. const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}_instant`;