diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..654c6d4 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets). + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md). diff --git a/.changeset/client-avatar-url-align.md b/.changeset/client-avatar-url-align.md new file mode 100644 index 0000000..3bc211e --- /dev/null +++ b/.changeset/client-avatar-url-align.md @@ -0,0 +1,5 @@ +--- +"@rei-standard/amsg-client": minor +--- + +`avatarUrl` 本地预检改用 `@rei-standard/amsg-shared` 的统一校验,与 server / instant 对齐。现在非法(非 `data:`)URL —— 例如缺少协议的 `foo.com/a.png` —— 也会在客户端被 `console.warn` 并置空;此前 client 只检查 `data:` 与长度,会放行这类 URL(之后由服务端兜底置空)。软清空策略不变:装饰性字段不合法时只做清空,不会让整条请求失败。 diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..ea40a3d --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["rei-standard-examples"] +} diff --git a/.changeset/instant-gzip-request-body.md b/.changeset/instant-gzip-request-body.md new file mode 100644 index 0000000..6b824bd --- /dev/null +++ b/.changeset/instant-gzip-request-body.md @@ -0,0 +1,5 @@ +--- +"@rei-standard/amsg-instant": minor +--- + +接收端支持 gzip 压缩的请求体。带 `X-Amsg-Request-Encoding: gzip` 头的请求会先 gunzip 再解析,不带这个头的请求按原样读取,行为不变。CORS 预检白名单里也加上了这个头。这样 `@rei-standard/amsg-client` 的 `deliver({ compressRequest })` 就能直接发到 `amsg-instant` 的 `/instant` / `/continue`,不用自己在后端解压。 diff --git a/.changeset/server-vapid-subject-fix.md b/.changeset/server-vapid-subject-fix.md new file mode 100644 index 0000000..7c9ec1d --- /dev/null +++ b/.changeset/server-vapid-subject-fix.md @@ -0,0 +1,5 @@ +--- +"@rei-standard/amsg-server": patch +--- + +VAPID subject 规范化支持 `https:` 形式:RFC 8292 允许 subject 使用 `https:`,规范化时按原样保留,不另加 `mailto:` 前缀。reasoning 私有思考过滤、`avatarUrl` 校验、VAPID subject 规范化统一改用 `@rei-standard/amsg-shared` 的实现。 diff --git a/.changeset/shared-validation-vapid-reasoning.md b/.changeset/shared-validation-vapid-reasoning.md new file mode 100644 index 0000000..501f4e5 --- /dev/null +++ b/.changeset/shared-validation-vapid-reasoning.md @@ -0,0 +1,9 @@ +--- +"@rei-standard/amsg-shared": minor +--- + +新增三组共享纯函数,让 server / instant / client 复用同一份规则,不再各自维护副本: + +- `validateAvatarUrl`(含 `isValidUrl` 与 `AVATAR_URL_MAX_LENGTH`)—— 头像 URL 校验 +- `normalizeVapidSubject` —— VAPID subject 规范化(`mailto:` / `https:` 均保留,裸邮箱补 `mailto:`) +- `readReasoningContent` / `stripReasoningTags` —— 读取推理内容与剥离私有 `` 链式思考 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2abe9dd..ac70f7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,41 +2,25 @@ name: Release on: push: - tags: - # Per-package release tags only. Historical `v*` tags (v2.1.0, etc.) - # were left over from when this repo was a single package — the root - # package.json is now `private: true` with no version field, so a - # `v*` tag has no semantic meaning here. Manual sweep-all is still - # available via `workflow_dispatch`. - - 'rei-standard-amsg-*@*' + branches: + - main workflow_dispatch: -# Per-ref concurrency group: each tag (or workflow_dispatch ref) gets its -# own queue, so coordinated multi-package releases (e.g. instant + server -# + client tags pushed together) all run in parallel without cancelling -# each other. -# -# Earlier this was `group: release` (single global queue). Combined with -# `cancel-in-progress: false`, GitHub Actions' semantics ("1 running + 1 -# pending max; new arrivals evict the pending one") meant pushing N tags -# at once kept only the first running + the last pending — middle tags -# were silently cancelled. Per-ref grouping fixes this; npm-side races -# are already prevented by scripts/publish-workspaces.mjs filtering on -# GITHUB_REF (only the triggering tag's package is published), with -# `isVersionPublished()` as the idempotent backstop. -# -# `cancel-in-progress: false` is still important within a single ref: -# half-finished publishes must never be killed mid-flight. +# One release run at a time. The Changesets action either opens/updates the +# "Version Packages" PR (when changesets are pending) or publishes the +# already-versioned packages (when that PR has merged) — never both in +# parallel, so a single global queue is what we want here. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write # create the Version Packages PR + push version-bump commit / git tags + pull-requests: write # open / update the Version Packages PR + id-token: write # npm provenance via OIDC trusted publishing jobs: - publish: + release: runs-on: ubuntu-latest steps: @@ -77,7 +61,25 @@ jobs: - name: Build and test run: npm run ci - - name: Publish public workspaces + - name: Create Release PR or publish + uses: changesets/action@v1 + with: + # When changesets are pending: open/refresh the "Version Packages" PR. + # `npm run version` runs `changeset version` (bump versions + write + # CHANGELOGs) AND `npm install --package-lock-only`. The lockfile step + # is required: `changeset version` only touches package.json/CHANGELOG, + # but this repo commits package-lock.json with workspace versions and + # internal dep ranges. Without the refresh, the merged Version PR would + # leave the lockfile stale and the next `npm ci` (above) would fail + # before publishing. + # When that PR has merged (no pending changesets, versions ahead of + # npm): run `npm run release` → `changeset publish` to publish each + # bumped package and push its git tag. + version: npm run version + publish: npm run release env: - NPM_PUBLISH_PROVENANCE: 'true' - run: npm run publish:workspaces + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Keep npm provenance on publish (changeset publish honours this + # the same way `npm publish --provenance` does; needs npm >= 9 and + # the id-token: write permission above). + NPM_CONFIG_PROVENANCE: 'true' diff --git a/README.md b/README.md index be7ea4f..482df8c 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,21 @@ ## 📦 包 -| 包 | 版本 | 用途 | -|---|---|---| -| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.2.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | -| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.9.0` | 一次性即时推送(SSE 默认传输、always-on Web Push backup) | -| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.5.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | -| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.4.0` | 浏览器 SDK:加密、请求封装、Push 订阅、SSE consumer | -| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.2.0` | Service Worker:推送展示、离线队列、delivery dedupe | +| 包 | 用途 | +|---|---| +| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | 推送 schema(`AmsgPush` 判别联合 + builders + 类型守卫) | +| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | 一次性即时推送(SSE 默认传输、always-on Web Push backup) | +| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | +| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | 浏览器 SDK:加密、请求封装、Push 订阅、deliver() 送达裁决 / SSE consumer | +| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | Service Worker:推送展示、离线队列、delivery dedupe | `amsg-shared` 是依赖图最底层:其他四个包都依赖它,反过来不行;它本身零运行时依赖。 **怎么挑服务端包**:只发"按钮点了就立刻推一条" → `amsg-instant`;要定时或周期任务 → `amsg-server`;两种都要就都装,共用同一套 VAPID 与 masterKey。 -### 协调发布说明:稳定版发布(shared 0.2.0 / instant 0.9.0 / sw 2.2.0 / client 2.4.0 / server 2.5.0) +### 版本与发布 -本轮补上 SSE + Web Push backup 的同 key 去重链路,并将相关包作为稳定版发布。`amsg-server` 没有运行时行为改动,只做 shared 依赖协调发版。 - -- `@rei-standard/amsg-shared`:`0.1.0` → `0.2.0` -- `@rei-standard/amsg-instant`:`0.8.2` → `0.9.0` -- `@rei-standard/amsg-server`:`2.4.1` → `2.5.0` -- `@rei-standard/amsg-sw`:`2.1.1` → `2.2.0` -- `@rei-standard/amsg-client`:`2.3.0` → `2.4.0` - -包间依赖一律使用**精确版本**(不带 `^`),避免 npm 在生态系统里解析出混版本图。本轮重点是:`amsg-instant` 默认 SSE 传输与 always-on Web Push backup、`amsg-client` 的 SSE consumer、`amsg-sw` 的 delivery dedupe / `REI_AMSG_DELIVER` bridge,以及 shared 的 `notification.silent` 类型补齐。 +版本号、CHANGELOG 与发布由 [Changesets](https://github.com/changesets/changesets) 管理。五个包各自独立版本(不绑成同一个号)。发布流程见 [`RELEASING.md`](./RELEASING.md):写 changeset → 合到 `main` → CI 开「Version Packages」PR,合并该 PR 即发版。 **安装最新版(`latest` dist-tag)**: @@ -34,9 +26,9 @@ npm install @rei-standard/amsg-shared @rei-standard/amsg-instant @rei-standard/amsg-server @rei-standard/amsg-sw @rei-standard/amsg-client ``` -## 三轴推送语义(Three-axis push schema) +## 推送 schema -每一条推送都由三个**正交**的维度描述。把"用什么方式发出去"(dispatch)、"业务命名空间"(business)、"载荷里装的是什么"(content)拆开,让一个 axis 加值的时候不需要动另外两个 axis。 +每条推送用三个互不影响的维度描述:"用什么方式发出去"(dispatch)、"属于哪个业务"(business)、"载荷里装的是什么"(content)。三者拆开,给某一个维度加新值时,另外两个不用动。 | 轴 | 字段 | 取值 | 由谁定 | |---|---|---|---| @@ -90,7 +82,7 @@ npm install @rei-standard/amsg-client @rei-standard/amsg-sw ReiStandard/ ├── standards/ # 权威规范文本(端点、字段、错误码) ├── packages/rei-standard-amsg/ # 5 个发布到 npm 的 SDK 包 -│ ├── shared/ # 三轴推送契约(最底层,其他包都依赖) +│ ├── shared/ # 推送 schema(最底层,其他包都依赖) │ ├── server/ # 定时 / 周期消息(多租户 Blob + token) │ ├── instant/ # 一次性即时推送(无 DB / 无 cron) │ ├── client/ # 浏览器 SDK(加密、请求封装、Push 订阅) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..7345506 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,29 @@ +# 发布流程 + +本仓库用 [Changesets](https://github.com/changesets/changesets) 管理版本号、CHANGELOG 和发布。五个包(`shared` / `client` / `instant` / `server` / `sw`)各自独立版本。 + +## 怎么发版 + +1. **写 changeset**:在你的功能分支上跑 + + ```bash + npx changeset + ``` + + 交互里勾选这次改动涉及哪些包、各自选 bump 级别(`patch` / `minor` / `major`),再写一句面向用户的变更摘要。命令会在 `.changeset/` 下生成一个 Markdown 文件,跟代码一起提交进 PR。 + + > 只改文档、测试、CI 这类不影响发布产物的,可以不写 changeset。 + +2. **合并到 `main`**:你的功能 PR 正常评审、合并。 + +3. **「Version Packages」PR**:`main` 上一旦有待处理的 changeset,Release workflow 会自动开(或刷新)一个标题为 *Version Packages* 的 PR。这个 PR 会把 changeset 应用掉——按 bump 级别抬版本号、写进各包的 `CHANGELOG.md`、删掉已消费的 changeset 文件。`updateInternalDependencies: patch` 让被依赖包升版时,依赖方的内部依赖区间也跟着对齐。version 命令在 `changeset version` 之后还会跑 `npm install --package-lock-only` 刷新 `package-lock.json`,让锁文件里的 workspace 版本号与抬版后的 package.json 对齐,否则合并该 PR 后下一次 `npm ci` 会因锁文件过时而失败。 + +4. **合并「Version Packages」PR 即发版**:合并后,同一个 workflow 跑 `changeset publish`,把版本号领先于 npm 的包逐个发布(带 npm provenance),并推对应的 git tag。 + +## 内部依赖区间 + +四个上层包对 `@rei-standard/amsg-shared` 用 `^0.2.0`。在 0.x 上脱字号只放行同一 minor 内的补丁(`0.2.x`),所以 shared 出补丁时消费者自动跟随、不必协调重发;shared 升 minor(如 `0.3.0`)不会被自动选中,要消费者在自己的 changeset 里显式升级区间。 + +## 权限与密钥 + +发布走 npm 的 OIDC trusted publishing,不需要在仓库里配 `NPM_TOKEN`。Release workflow 申请了 `id-token: write` 权限并把 npm 升到 `>= 11.5.1`,发布时带 `--provenance`。前提是 npm 侧已为这些包配好 trusted publisher(指向本仓库的 Release workflow)。`changesets/action` 开 PR / 推 tag 用的是 GitHub 自带的 `GITHUB_TOKEN`。 diff --git a/bump.mjs b/bump.mjs deleted file mode 100644 index 9ee0b9d..0000000 --- a/bump.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -function updatePkg(pkgPath, version, sharedDep) { - const file = path.resolve(pkgPath); - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - json.version = version; - if (json.dependencies && json.dependencies['@rei-standard/amsg-shared']) { - json.dependencies['@rei-standard/amsg-shared'] = sharedDep; - } - fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); -} - -updatePkg('packages/rei-standard-amsg/shared/package.json', '0.2.0', null); -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/docs/VERCEL_TEST_DEPLOY.md b/docs/VERCEL_TEST_DEPLOY.md index 1452384..49071e1 100644 --- a/docs/VERCEL_TEST_DEPLOY.md +++ b/docs/VERCEL_TEST_DEPLOY.md @@ -10,11 +10,9 @@ ## 环境变量 - 必需:`TENANT_DATABASE_URL` -- 可选:`INIT_SECRET`(服务端启用初始化鉴权时再配置) - -可选: - -- `TEST_USER_ID` +- 可选: + - `INIT_SECRET`(服务端启用初始化鉴权时再配置) + - `TEST_USER_ID` 说明:测试端点会先调用 `init-tenant`,然后自动完成 `get-user-key`、`schedule-message`、`send-notifications` 验证。 diff --git a/package-lock.json b/package-lock.json index fdc949f..f387f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "packages/rei-standard-amsg/*", "examples" ], + "devDependencies": { + "@changesets/cli": "^2.31.0" + }, "engines": { "node": ">=20" } @@ -25,6 +28,258 @@ "node": ">=20" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@changesets/apply-release-plan": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/config": "^3.1.4", + "@changesets/get-version-range-type": "^0.4.0", + "@changesets/git": "^3.0.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "detect-indent": "^6.0.0", + "fs-extra": "^7.0.1", + "lodash.startcase": "^4.4.0", + "outdent": "^0.5.0", + "prettier": "^2.7.1", + "resolve-from": "^5.0.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/assemble-release-plan": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/changelog-git": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", + "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0" + } + }, + "node_modules/@changesets/cli": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/changelog-git": "^0.2.1", + "@changesets/config": "^3.1.4", + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@changesets/write": "^0.4.0", + "@inquirer/external-editor": "^1.0.2", + "@manypkg/get-packages": "^1.1.3", + "ansi-colors": "^4.1.3", + "enquirer": "^2.4.1", + "fs-extra": "^7.0.1", + "mri": "^1.2.0", + "package-manager-detector": "^0.2.0", + "picocolors": "^1.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "spawndamnit": "^3.0.1", + "term-size": "^2.1.0" + }, + "bin": { + "changeset": "bin.js" + } + }, + "node_modules/@changesets/config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/logger": "^0.1.1", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1", + "micromatch": "^4.0.8" + } + }, + "node_modules/@changesets/errors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", + "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", + "dev": true, + "license": "MIT", + "dependencies": { + "extendable-error": "^0.1.5" + } + }, + "node_modules/@changesets/get-dependents-graph": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "picocolors": "^1.1.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/get-release-plan": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/get-version-range-type": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", + "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/git": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", + "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@manypkg/get-packages": "^1.1.3", + "is-subdir": "^1.1.1", + "micromatch": "^4.0.8", + "spawndamnit": "^3.0.1" + } + }, + "node_modules/@changesets/logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", + "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/parse": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.3.tgz", + "integrity": "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "js-yaml": "^4.1.1" + } + }, + "node_modules/@changesets/pre": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", + "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1" + } + }, + "node_modules/@changesets/read": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.7.tgz", + "integrity": "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/parse": "^0.4.3", + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "p-filter": "^2.1.0", + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/should-skip-package": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", + "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/types": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", + "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/write": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", + "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "human-id": "^4.1.1", + "prettier": "^2.7.1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -467,6 +722,28 @@ "node": ">=18" } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -506,6 +783,78 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@manypkg/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@types/node": "^12.7.1", + "find-up": "^4.1.0", + "fs-extra": "^8.1.0" + } + }, + "node_modules/@manypkg/find-root/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/find-root/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", + "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@changesets/types": "^4.0.1", + "@manypkg/find-root": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "^11.0.0", + "read-yaml-file": "^1.1.0" + } + }, + "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", + "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/get-packages/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/@neondatabase/serverless": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", @@ -528,6 +877,44 @@ "node": "^14.16.0 || >=16.0.0" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rei-standard/amsg-client": { "resolved": "packages/rei-standard-amsg/client", "link": true @@ -947,6 +1334,26 @@ "node": ">= 14" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -954,6 +1361,23 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -966,12 +1390,38 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/better-path-resolve": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", + "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-windows": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1004,6 +1454,13 @@ "node": ">=8" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1047,6 +1504,21 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1064,6 +1536,29 @@ } } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1073,6 +1568,20 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1115,6 +1624,54 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extendable-error": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", + "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1133,17 +1690,59 @@ } } }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } }, "node_modules/fsevents": { "version": "2.3.3", @@ -1160,6 +1759,47 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/http_ece": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", @@ -1182,12 +1822,112 @@ "node": ">= 14" } }, + "node_modules/human-id": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", + "integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-subdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", + "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "better-path-resolve": "1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1198,6 +1938,39 @@ "node": ">=10" } }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1249,6 +2022,26 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1259,6 +2052,43 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -1287,6 +2117,16 @@ "ufo": "^1.6.1" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1315,6 +2155,115 @@ "node": ">=0.10.0" } }, + "node_modules/outdent": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", + "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1431,6 +2380,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1535,6 +2494,100 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-yaml-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", + "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.5", + "js-yaml": "^3.6.1", + "pify": "^4.0.1", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/read-yaml-file/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/read-yaml-file/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1563,6 +2616,17 @@ "node": ">=8" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1608,6 +2672,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1634,6 +2722,65 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1644,6 +2791,17 @@ "node": ">= 12" } }, + "node_modules/spawndamnit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", + "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "cross-spawn": "^7.0.5", + "signal-exit": "^4.0.1" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1653,6 +2811,36 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1676,6 +2864,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -1723,6 +2924,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -1820,6 +3034,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -1839,6 +3063,22 @@ "node": ">= 16" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -1850,10 +3090,10 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.5.0", + "version": "2.7.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1868,7 +3108,7 @@ "version": "0.9.1", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1884,7 +3124,7 @@ "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", - "@rei-standard/amsg-shared": "0.2.0", + "@rei-standard/amsg-shared": "^0.2.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -1926,7 +3166,7 @@ "version": "2.3.1", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/package.json b/package.json index 18abd99..44d4ecf 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "build": "npm run build --workspaces --if-present", "test": "npm run test --workspaces --if-present", "ci": "npm run check:esm && npm run build && npm run test", - "publish:workspaces": "node scripts/publish-workspaces.mjs" + "version": "changeset version && npm install --package-lock-only", + "release": "changeset publish" }, "workspaces": [ "packages/rei-standard-amsg/*", "examples" - ] + ], + "devDependencies": { + "@changesets/cli": "^2.31.0" + } } diff --git a/packages/rei-standard-amsg/README.md b/packages/rei-standard-amsg/README.md index 9e339ac..39b8459 100644 --- a/packages/rei-standard-amsg/README.md +++ b/packages/rei-standard-amsg/README.md @@ -4,7 +4,7 @@ | Package | 版本 | 用途 | |---------|------|------| -| [`@rei-standard/amsg-shared`](./shared/README.md) | `0.2.0` | 三轴推送契约、builders、类型守卫 | +| [`@rei-standard/amsg-shared`](./shared/README.md) | `0.2.0` | 推送 schema、builders、类型守卫 | | [`@rei-standard/amsg-instant`](./instant/README.md) | `0.9.0` | 一次性即时推送 handler(SSE 默认传输 / always-on Web Push backup) | | [`@rei-standard/amsg-server`](./server/README.md) | `2.5.0` | 定时 + 周期消息:Blob 租户配置、token 鉴权、标准 handlers | | [`@rei-standard/amsg-client`](./client/README.md) | `2.4.0` | 浏览器 SDK:加密、请求封装、Push 订阅、SSE consumer | diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index 82604fb..7047e24 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog — @rei-standard/amsg-client +## 2.7.0 — `deliver()` 新增 `compressRequest` 请求体 gzip 压缩 + +给 `deliver()` 加一个**可选**的 `compressRequest`,把要发出去的请求体在上网线之前 gzip 压一下。中文 + 重复结构的 JSON 压缩比很高(实测 ~322KB 能压到 ~50KB),网线上字节小了,大 body 在慢/不稳的上行链路上就能在「发了没回应就杀」的超时之前传完。压的是**请求**,不是响应;上下文内容一字不动,只是传输层省字节。 + +不传 = 关闭 = 行为完全不变(向后兼容)。SSE 与 JSON 两条 transport 共用同一请求体,压缩对两者一致生效。解压由接收端(worker)负责,客户端只压不解。 + +### New + +- 新增 `deliver()` 选项 `compressRequest`: + - 不传 / falsy ⇒ 关闭,照常发明文 JSON。 + - `true` 或 `{}` ⇒ 启用,阈值取默认 **16384 字节(16 KB)**。 + - `{ thresholdBytes: N }` ⇒ 启用并自定义阈值。 +- 启用后**仅当**请求体 UTF-8 字节数超过阈值、**且**运行时有 `CompressionStream` 时才压缩;否则原样发明文(优雅降级,压缩过程任何异常都兜回明文,绝不让它把发送搞挂)。 +- 压缩时请求体发**原始 gzip 字节**,并加自定义头 `X-Amsg-Request-Encoding: gzip`(特意不用标准 `Content-Encoding`——那个会被 CDN / 代理自动解压导致双重解压)。接收端据此头自行 gunzip。 + +## 2.6.0 — `deliver()` 新增 `onRawRead` 原始读遥测钩子 + +给 `deliver()` 加一个**可选**的 `onRawRead` 钩子,专供排查 SSE 链路用。SSE transport 每次 `reader.read()` 后回调,把原始字节信息交给调用方,便于回答「连接静默期里到底有没有字节真的到达客户端」这类问题。 + +不传 = 行为完全不变;SSE 解析逻辑(含 `:` 注释行的处理)一字未动。 + +### New + +- 新增 `deliver()` 选项 `onRawRead(meta)`:SSE transport 每次 `reader.read()` 之后触发,`meta` 含 `ts` / `byteLength` / `done` / `textPreview`(本次数据解码后的前 120 字符,**保留 `:` 注释行**,能看到平时被解析层跳过的 keepalive 帧);首帧额外带 `status` / `contentEncoding` / `contentType` 三个响应元信息。 +- 钩子抛错被吞,不影响送达主流程;`textPreview` 用独立 decoder 取样,不干扰流式解析。 + ## 2.5.0 — `deliver()` 平台无关送达 primitive 把"发出去"和"业务上是否真送达"在 API 层显式分开。新增 `client.deliver()` 作为新代码的首选入口;老的 `sendInstant()` / `consumeInstantStream()` 仍可用但降级为低级 transport,配 opt-in dev warning 引导迁移。SSE 与 JSON 两条 transport 一并升级到统一的送达协调层,调用方无需感知。 diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index 7595c6a..b65dd97 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -165,6 +165,8 @@ interface DeliverOptions { timeoutMs: number; // 总预算(含 transport + grace) onChunk?: (payload: unknown) => Promise | void; // 可选 SSE 每帧钩子,抛错被吞 + onRawRead?: (meta: RawReadMeta) => void; // 可选 SSE 原始读遥测,排查链路用;抛错被吞 + // 每次 reader.read() 后触发,保留 ':' 注释行 postTransportGraceMs?: number; // transport 结束后等观察的 grace // 默认 = min(remaining, max(5000, timeoutMs * 0.1)) // cancel 路径下生效的是 grace / 2 @@ -176,6 +178,13 @@ interface DeliverOptions { // / X-Client-Token / Authorization authorization?: string; // 透传成 Authorization header(与 sendInstant 对齐) endpointPath?: string; // 默认 '/instant',可改 '/continue' 续跑 + compressRequest?: boolean | { thresholdBytes?: number }; // 可选请求体 gzip。不传/falsy = 关(行为不变) + // true / {} = 开,阈值默认 16384 字节(16KB) + // { thresholdBytes: N } = 开 + 自定义阈值 + // 仅当 body 超阈值且运行时有 CompressionStream 才压; + // 否则发明文(优雅降级,绝不抛)。压时发 gzip 字节 + + // 头 X-Amsg-Request-Encoding: gzip(非标准 Content- + // Encoding),由接收端 gunzip。SSE / JSON 两路通用。 } interface ObservedDeliveryReceipt { @@ -183,8 +192,22 @@ interface ObservedDeliveryReceipt { sessionId?: string; // ↑ channel?: string; // 'sw' / 'ipc' / 'native' / 'poll' / 任意诊断 label } + +interface RawReadMeta { + ts: number; // Date.now() + byteLength: number; // 本次 reader.read() 拿到的字节数 + done: boolean; // 流是否结束 + textPreview: string; // 本次数据解码后的前 120 字符,保留 ':' keepalive 注释行 + status?: number; // 仅首帧带:响应状态码 + contentEncoding?: string | null; // 仅首帧带:响应 Content-Encoding(查是否被边缘压缩) + contentType?: string | null; // 仅首帧带 +} ``` +> `onRawRead` 是诊断钩子:SSE 解析层默认丢弃 `:` 注释行(含每秒一发的 keepalive),出问题时无从判断「静默期里到底有没有字节到达」。挂上它就能在 raw `reader.read()` 这一层看到每次读到的原始字节与 keepalive 帧。不传则零开销、行为不变。 + +> `compressRequest` 用于大 body 上传:开启后,要发的 JSON 在上网线前 gzip(中文 + 重复结构压缩比很高),网线上字节小了就能在慢/不稳链路的发送超时之前传完,且上下文一字不动。仅当 body 超阈值且运行时支持 `CompressionStream` 才压,否则照常发明文;压缩出错也兜回明文,永不影响发送。压缩的是请求体,与响应 / `onChunk` / `onRawRead` 无关。接收端需按 `X-Amsg-Request-Encoding: gzip` 头自行解压。 + ### `delivery.mode` 必须显式选 | mode | 何时用 | outcome 取值 | diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index b9ab75e..5cba075 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", + "version": "2.7.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", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index aef0ff4..0407174 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -28,7 +28,7 @@ * await client.scheduleMessage({ ... }); */ -import { base64UrlToBytes } from '@rei-standard/amsg-shared'; +import { base64UrlToBytes, validateAvatarUrl } from '@rei-standard/amsg-shared'; // `TextEncoder` is stateless — hoist once instead of allocating a fresh // instance for every encrypt + payload-size check. @@ -193,15 +193,41 @@ const TEXT_ENCODER = new TextEncoder(); * `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+. + * @property {(meta: RawReadMeta) => void} [onRawRead] - Optional raw-read telemetry hook for the + * foreground SSE transport. Fires once per `reader.read()` BEFORE any SSE parsing/filtering, so it + * sees every byte that reached the client — including `: keepalive` comment frames that the parser + * silently drops. Use it to tell "connection alive but no business data" apart from "no bytes flowing + * at all" when diagnosing stalled streams. Purely observational: throws are swallowed and never affect + * transport. Not invoked for the JSON transport. + * @property {boolean | { thresholdBytes?: number }} [compressRequest] - Opt-in gzip of the request + * BODY before it is sent (applies to both the SSE and JSON transports — it compresses the request, + * not the response). Omit / falsy = OFF and behavior is fully unchanged (backward compatible). + * `true` or `{}` enables it at the default 16384-byte (16 KB) threshold; `{ thresholdBytes: N }` + * sets a custom threshold. When enabled, the body is gzip-compressed only if its UTF-8 byte length + * exceeds the threshold AND the runtime provides `CompressionStream`; otherwise it is sent as + * plaintext (graceful degradation, never throws). On compression the request gains the custom + * header `X-Amsg-Request-Encoding: gzip` (NOT standard `Content-Encoding`, which CDNs / proxies + * would auto-decompress and double-decode) and the body is the raw gzip bytes — the receiving + * worker is responsible for gunzipping. Use it when delivering large bodies over slow / flaky + * uplinks where a big upload can outrun the connection's send timeout. */ /** - * Max length of `avatarUrl` accepted by local preflight (2 KB). Mirrors - * `@rei-standard/amsg-instant` / `@rei-standard/amsg-server` server-side - * limits — kept in lockstep on purpose so client-side rejects match what - * the server would reject. + * Metadata for a single raw `reader.read()` on the SSE body, passed to + * `DeliverOptions.onRawRead`. The response-meta fields + * (`status` / `contentEncoding` / `contentType`) are only populated on the + * first invocation; later calls omit them. + * + * @typedef {Object} RawReadMeta + * @property {number} ts - `Date.now()` at the moment the read resolved. + * @property {number} byteLength - Bytes in this chunk (`value?.byteLength ?? 0`). + * @property {boolean} done - The `done` flag from `reader.read()`. + * @property {string} textPreview - First ~120 chars of this chunk decoded as UTF-8, + * WITHOUT any keepalive/comment filtering (so `:`-prefixed lines stay visible). + * @property {string|null} [contentEncoding] - `res.headers.get('content-encoding')`. First call only. + * @property {string|null} [contentType] - `res.headers.get('content-type')`. First call only. + * @property {number} [status] - `res.status`. First call only. */ -const AVATAR_URL_MAX_LENGTH = 2048; function makeLocalError(code, message, details) { const err = new Error(`[rei-standard-amsg-client] ${message}`); @@ -241,6 +267,68 @@ function classifyContentType(contentType) { return 'unknown'; } +/** + * Default size floor for request-body gzip: bodies at or below this are not + * worth compressing (the gzip header/overhead can outweigh the gain on tiny + * payloads). 16 KB matches the contract documented on `DeliverOptions.compressRequest`. + */ +const COMPRESS_REQUEST_DEFAULT_THRESHOLD = 16384; + +/** + * Custom request header used to mark a gzip-compressed body. Deliberately NOT + * the standard `Content-Encoding` — CDNs / reverse proxies (Cloudflare, etc.) + * auto-decompress `Content-Encoding: gzip` on the way in, which would double- + * decompress and corrupt the body. The receiving worker keys off this custom + * header to know it must gunzip the body itself. + */ +const COMPRESS_REQUEST_HEADER = 'X-Amsg-Request-Encoding'; + +/** + * Optionally gzip a request body string before it hits `fetch`. + * + * Pure optimization with graceful degradation: returns the original plaintext + * body (and no extra header) whenever compression is disabled, the body is at + * or below the threshold, the runtime lacks `CompressionStream`, or anything + * throws. The wire bytes shrink (Chinese / repetitive JSON compresses ~5-8x) + * so large uploads finish before flaky links time out — without dropping any + * context. Decompression is the receiving worker's job (keyed off + * `X-Amsg-Request-Encoding: gzip`). + * + * @param {string} body - The already-serialized request body (plaintext JSON). + * @param {boolean | { thresholdBytes?: number } | undefined} compressRequest + * `undefined`/falsy ⇒ disabled (no-op, backward compatible). `true` / `{}` ⇒ + * enabled at the 16 KB default. `{ thresholdBytes: N }` ⇒ enabled at N bytes. + * @returns {Promise<{ body: string | Uint8Array, header: string | null }>} + * `header` is the gzip marker header name to set when compression happened, + * or `null` to send plaintext with no extra header. + */ +async function maybeCompressRequestBody(body, compressRequest) { + // Disabled / no opt-in ⇒ behavior unchanged. + if (!compressRequest) return { body, header: null }; + + const threshold = + typeof compressRequest === 'object' && typeof compressRequest.thresholdBytes === 'number' + ? compressRequest.thresholdBytes + : COMPRESS_REQUEST_DEFAULT_THRESHOLD; + + try { + if (typeof CompressionStream === 'undefined') return { body, header: null }; + + const bytes = new TextEncoder().encode(body); + if (bytes.length <= threshold) return { body, header: null }; + + const gz = new Uint8Array( + await new Response( + new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')) + ).arrayBuffer() + ); + return { body: gz, header: COMPRESS_REQUEST_HEADER }; + } catch { + // Compression is an optimization, never a failure mode: fall back to plaintext. + return { body, header: null }; + } +} + export class ReiClient { /** * @param {ReiClientConfig} config @@ -534,7 +622,8 @@ export class ReiClient { } const { delivery, timeoutMs, onChunk, postTransportGraceMs, - signal, headers, authorization, endpointPath, + signal, headers, authorization, endpointPath, onRawRead, + compressRequest, } = opts; if (!delivery || typeof delivery !== 'object') { @@ -627,6 +716,8 @@ export class ReiClient { const result = await this._runInstantTransport(built, { signal: internalAbort.signal, onChunk: wrappedOnChunk, + onRawRead, + compressRequest, }); if (finalized) return; transportEnded = true; @@ -890,16 +981,7 @@ export class ReiClient { */ _sanitizeAvatarUrl(target) { if (!target || typeof target !== 'object') return false; - const value = target.avatarUrl; - if (value === undefined || value === null) return false; - let reason = null; - if (typeof value !== 'string') { - reason = 'avatarUrl 必须是字符串'; - } else if (/^data:/i.test(value)) { - reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'; - } else if (value.length > AVATAR_URL_MAX_LENGTH) { - reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`; - } + const reason = validateAvatarUrl(target.avatarUrl); if (reason) { console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason); target.avatarUrl = null; @@ -979,14 +1061,23 @@ export class ReiClient { * * @private * @param {{ url: string, headers: Record, body: string }} built - * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise | void }} opts + * @param {{ signal: AbortSignal, onChunk?: (p: unknown) => Promise | void, onRawRead?: (meta: RawReadMeta) => void, compressRequest?: boolean | { thresholdBytes?: number } }} opts + * `onRawRead` is forwarded to the SSE consumer for raw read-loop telemetry (see `DeliverOptions.onRawRead`). + * `compressRequest` opts the request body into gzip before `fetch` (see `DeliverOptions.compressRequest`). * @returns {Promise<{ kind: 'sse' } | { kind: 'json', body: unknown }>} */ async _runInstantTransport(built, opts) { - const { signal, onChunk } = opts; + const { signal, onChunk, onRawRead, compressRequest } = opts; const { url, headers, body } = built; - const res = await fetch(url, { method: 'POST', headers, body, signal }); + // Optionally gzip the request body (opt-in, graceful fallback to plaintext). + const { body: wireBody, header: compressionHeader } = + await maybeCompressRequestBody(body, compressRequest); + const wireHeaders = compressionHeader + ? { ...headers, [compressionHeader]: 'gzip' } + : headers; + + const res = await fetch(url, { method: 'POST', headers: wireHeaders, body: wireBody, signal }); if (!res.ok) { const text = await res.text().catch(() => ''); @@ -995,11 +1086,20 @@ export class ReiClient { throw err; } - const contentType = res.headers.get('content-type') || ''; + const rawContentType = res.headers.get('content-type'); + const contentType = rawContentType || ''; const kind = classifyContentType(contentType); if (kind === 'sse') { if (!res.body) throw new Error('Response body is null'); - await this._consumeSseStream(res, { onPayload: onChunk }); + await this._consumeSseStream(res, { + onPayload: onChunk, + onRawRead, + responseMeta: { + status: res.status, + contentEncoding: res.headers.get('content-encoding'), + contentType: rawContentType, + }, + }); return { kind: 'sse' }; } if (kind === 'json') { @@ -1017,16 +1117,54 @@ export class ReiClient { * * @private * @param {Response} res - * @param {{ onPayload?: (p: unknown) => Promise | void }} opts + * @param {{ + * onPayload?: (p: unknown) => Promise | void, + * onRawRead?: (meta: RawReadMeta) => void, + * responseMeta?: { status?: number, contentEncoding?: string | null, contentType?: string | null } + * }} opts + * `onRawRead` (if supplied) fires once per `reader.read()` before any SSE parsing/filtering — it sees + * raw bytes including `: keepalive` comment frames. Throws from it are swallowed. `responseMeta` is + * attached to the FIRST `onRawRead` call only. See `DeliverOptions.onRawRead`. * @returns {Promise} */ async _consumeSseStream(res, opts) { - const { onPayload } = opts; + const { onPayload, onRawRead, responseMeta } = opts; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let thrown; + // Raw read-loop telemetry (opt-in via onRawRead). Kept completely + // separate from the parsing path: a one-shot decoder for the preview so + // it never perturbs the streaming `decoder` above, and the first call + // carries response meta (status / encoding / content-type). + const previewDecoder = onRawRead ? new TextDecoder() : null; + let rawReadFired = false; + const emitRawRead = (done, value) => { + if (!onRawRead) return; + try { + let textPreview = ''; + if (value && value.byteLength) { + // One-shot decode (no { stream: true }) so we don't carry state + // between calls and disturb the main buffer's decoder. + textPreview = previewDecoder.decode(value).slice(0, 120); + } + const meta = { + ts: Date.now(), + byteLength: value && value.byteLength ? value.byteLength : 0, + done: !!done, + textPreview, + }; + if (!rawReadFired) { + meta.status = responseMeta ? responseMeta.status : undefined; + meta.contentEncoding = responseMeta ? responseMeta.contentEncoding : undefined; + meta.contentType = responseMeta ? responseMeta.contentType : undefined; + } + rawReadFired = true; + onRawRead(meta); + } catch { /* telemetry must never break the transport */ } + }; + // 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`. @@ -1069,6 +1207,7 @@ export class ReiClient { try { while (true) { const { done, value } = await reader.read(); + emitRawRead(done, value); if (done) { // Flush any tail bytes the decoder held back (partial UTF-8 // sequences split across the final chunk boundary). diff --git a/packages/rei-standard-amsg/client/test/avatar-url.test.mjs b/packages/rei-standard-amsg/client/test/avatar-url.test.mjs new file mode 100644 index 0000000..27fa525 --- /dev/null +++ b/packages/rei-standard-amsg/client/test/avatar-url.test.mjs @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ReiClient } from '../src/index.js'; + +function makeClient() { + return new ReiClient({ baseUrl: 'https://example.com', instantEncryption: false }); +} + +// Run a `_sanitizeAvatarUrl` call with console.warn stubbed (keeps the test +// output clean) and report whether a warning fired. +function sanitize(client, target) { + const original = console.warn; + let warned = false; + console.warn = () => { warned = true; }; + try { + const stripped = client._sanitizeAvatarUrl(target); + return { stripped, warned }; + } finally { + console.warn = original; + } +} + +test('_sanitizeAvatarUrl: valid https URL is kept', () => { + const client = makeClient(); + const target = { avatarUrl: 'https://cdn.example.com/a.png' }; + const { stripped, warned } = sanitize(client, target); + assert.equal(stripped, false); + assert.equal(warned, false); + assert.equal(target.avatarUrl, 'https://cdn.example.com/a.png'); +}); + +test('_sanitizeAvatarUrl: absent avatarUrl is a no-op', () => { + const client = makeClient(); + const target = { contactName: 'Rei' }; + const { stripped } = sanitize(client, target); + assert.equal(stripped, false); + assert.equal('avatarUrl' in target, false); +}); + +test('_sanitizeAvatarUrl: data: URI is stripped', () => { + const client = makeClient(); + const target = { avatarUrl: 'data:image/png;base64,AAAA' }; + const { stripped, warned } = sanitize(client, target); + assert.equal(stripped, true); + assert.equal(warned, true); + assert.equal(target.avatarUrl, null); +}); + +test('_sanitizeAvatarUrl: malformed non-data URL is now stripped (aligned with server/instant)', () => { + // Behavior change: before the shared-validator alignment, client only checked + // data: + length, so a scheme-less URL passed through. server/instant always + // rejected it; client now matches. + const client = makeClient(); + const target = { avatarUrl: 'foo.com/avatar.png' }; + const { stripped, warned } = sanitize(client, target); + assert.equal(stripped, true); + assert.equal(warned, true); + assert.equal(target.avatarUrl, null); +}); diff --git a/packages/rei-standard-amsg/client/test/deliver.test.mjs b/packages/rei-standard-amsg/client/test/deliver.test.mjs index 02eb7ef..6593690 100644 --- a/packages/rei-standard-amsg/client/test/deliver.test.mjs +++ b/packages/rei-standard-amsg/client/test/deliver.test.mjs @@ -1068,3 +1068,152 @@ test('Content-Type: parameter value containing media-type string is not mis-clas assert.deepEqual(result.detail.transportResponse, body); } finally { restore(); } }); + +// ─── compressRequest (request-body gzip, opt-in) ───────────────── +// +// Contract under test (must stay lockstep with the receiving worker): +// - enabled + body over threshold ⇒ fetch carries header +// `X-Amsg-Request-Encoding: gzip` and body is raw gzip bytes that +// gunzip back to the original plaintext JSON. +// - small body, or compressRequest omitted ⇒ plaintext, NO such header. +// - CompressionStream unavailable ⇒ graceful fallback to plaintext. + +import { gunzipSync } from 'node:zlib'; + +const COMPRESS_HEADER = 'X-Amsg-Request-Encoding'; + +// Read a header value from a fetch `init.headers` plain object, case-insensitively. +function headerOf(headers, name) { + if (!headers) return undefined; + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) return v; + } + return undefined; +} + +// Swap CompressionStream out for the duration of a test; restore after. +function withoutCompressionStream(fn) { + const original = globalThis.CompressionStream; + // eslint-disable-next-line no-undef + delete globalThis.CompressionStream; + return Promise.resolve() + .then(fn) + .finally(() => { globalThis.CompressionStream = original; }); +} + +test('compressRequest: enabled + large body → gzip header + body gunzips to original JSON', async () => { + const client = newClient(); + // A payload that serializes well past the default 16 KB threshold. + const payload = { kind: 'big', notes: Array.from({ length: 4000 }, (_, i) => `中文重复内容-${i % 7}`) }; + const expectedJson = JSON.stringify(payload); + + let captured; + const restore = installFetch((_url, init) => { + captured = init; + return makeJsonResponse({ ack: true }); + }); + + try { + await client.deliver(payload, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + compressRequest: true, + }); + + assert.equal(headerOf(captured.headers, COMPRESS_HEADER), 'gzip', 'gzip marker header must be set'); + assert.ok( + captured.body instanceof Uint8Array, + 'compressed body should be raw bytes, not a string' + ); + // Wire bytes should be much smaller than the plaintext for this payload. + assert.ok( + captured.body.byteLength < new TextEncoder().encode(expectedJson).length, + 'gzip body should be smaller than plaintext' + ); + const roundTrip = gunzipSync(Buffer.from(captured.body)).toString('utf8'); + assert.equal(roundTrip, expectedJson, 'gunzip(body) must equal the original JSON'); + } finally { restore(); } +}); + +test('compressRequest: custom thresholdBytes triggers compression on a smaller body', async () => { + const client = newClient(); + const payload = { tag: 'smallish', blob: 'x'.repeat(200) }; + const expectedJson = JSON.stringify(payload); + + let captured; + const restore = installFetch((_url, init) => { captured = init; return makeJsonResponse({ ack: true }); }); + + try { + await client.deliver(payload, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + compressRequest: { thresholdBytes: 16 }, + }); + assert.equal(headerOf(captured.headers, COMPRESS_HEADER), 'gzip'); + assert.ok(captured.body instanceof Uint8Array); + assert.equal(gunzipSync(Buffer.from(captured.body)).toString('utf8'), expectedJson); + } finally { restore(); } +}); + +test('compressRequest: small body (below threshold) → plaintext, no gzip header', async () => { + const client = newClient(); + const payload = { tag: 'tiny', n: 1 }; + const expectedJson = JSON.stringify(payload); + + let captured; + const restore = installFetch((_url, init) => { captured = init; return makeJsonResponse({ ack: true }); }); + + try { + await client.deliver(payload, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + compressRequest: true, // enabled, but body is far below 16 KB + }); + assert.equal(headerOf(captured.headers, COMPRESS_HEADER), undefined, 'no gzip header for small body'); + assert.equal(typeof captured.body, 'string', 'small body stays plaintext string'); + assert.equal(captured.body, expectedJson); + } finally { restore(); } +}); + +test('compressRequest: omitted → plaintext, no gzip header (backward compatible)', async () => { + const client = newClient(); + const payload = { kind: 'big', notes: Array.from({ length: 4000 }, (_, i) => `中文重复内容-${i % 7}`) }; + const expectedJson = JSON.stringify(payload); + + let captured; + const restore = installFetch((_url, init) => { captured = init; return makeJsonResponse({ ack: true }); }); + + try { + await client.deliver(payload, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + // compressRequest intentionally omitted + }); + assert.equal(headerOf(captured.headers, COMPRESS_HEADER), undefined); + assert.equal(typeof captured.body, 'string'); + assert.equal(captured.body, expectedJson); + } finally { restore(); } +}); + +test('compressRequest: CompressionStream unavailable → graceful fallback to plaintext', async () => { + const client = newClient(); + const payload = { kind: 'big', notes: Array.from({ length: 4000 }, (_, i) => `中文重复内容-${i % 7}`) }; + const expectedJson = JSON.stringify(payload); + + let captured; + const restore = installFetch((_url, init) => { captured = init; return makeJsonResponse({ ack: true }); }); + + try { + await withoutCompressionStream(async () => { + await client.deliver(payload, { + delivery: { mode: 'transport-only' }, + timeoutMs: 1000, + compressRequest: true, + }); + }); + assert.equal(headerOf(captured.headers, COMPRESS_HEADER), undefined, 'no header when CompressionStream missing'); + assert.equal(typeof captured.body, 'string', 'falls back to plaintext string'); + assert.equal(captured.body, expectedJson); + } finally { restore(); } +}); diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 99b7f59..51aa429 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -331,9 +331,7 @@ If you have a hook that builds its own pushPayload object, **set `sessionId: ctx - Adds `@rei-standard/amsg-shared` at exact version `0.1.0` (no caret). The coordinated minor upgrade is intentionally strict — npm shouldn't resolve a mixed-version graph across the ecosystem. -## Unreleased (pre-0.8.0) - -**Fix** +### Fix - **`/continue` 无 `onLLMOutput` 时给出清晰的 400 `CONTINUE_NOT_AVAILABLE`**:之前往一个没配 hook 的 handler POST `/continue` 会过 validation、进 `runAgenticLoop`、然后在 `ctx.onLLMOutput(...)` 上炸 TypeError、最终被当成 `HOOK_THREW` 报给客户端 + 推一条诊断 envelope。问题是「没钩子」是部署配置问题,不是钩子抛错,HOOK_THREW 把锅甩到了不存在的钩子上。现在在 handler 入口处直接拒,错误码明确指向缺 `onLLMOutput`。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index e6a2add..c0ffb8a 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -134,7 +134,7 @@ data: {} #### SSE backup push(0.9.0+) -正式环境推荐保持默认链路:SSE 正常流式返回,每条 payload enqueue 成功后也发一份 Web Push backup。它不是“断了才发”,而是 backup 常开;重复处理交给 `@rei-standard/amsg-sw` 的 delivery dedupe 解决。 +正式环境推荐保持默认链路:SSE 正常流式返回,每条 payload enqueue 成功后也发一份 Web Push backup。这份 backup 不是“断了才发”,而是默认常开;重复的部分交给 `@rei-standard/amsg-sw` 的 delivery dedupe 解决。 | 配置 | 默认值 | 行为 | 生产建议 | |------|--------|------|----------| @@ -580,7 +580,7 @@ self.addEventListener('push', (e) => e.waitUntil((async () => { if (!res.ok) return; // TTL 已过期/失败,不展示 data = await res.json(); } - if (data.type !== 'tool-request') return handle(data); + if (data.messageKind !== 'tool_request') return handle(data); // 2. fetch 之后 dedup —— claim 永久保留,靠 sweeper 清旧 const claimKey = `${data.sessionId}:${data.iteration}`; diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 106c782..dd37d7c 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 3077df3..e0499e4 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -266,11 +266,16 @@ export function createInstantHandler(options) { let rawBody; try { - rawBody = await request.text(); - } catch (_err) { + rawBody = await readRequestBodyText(request); + } catch (err) { return respond(400, { success: false, - error: { code: 'INVALID_PAYLOAD_FORMAT', message: '无法读取请求体' } + error: { + code: 'INVALID_PAYLOAD_FORMAT', + message: err && err.unsupportedEncoding + ? '运行时不支持解压 gzip 请求体(X-Amsg-Request-Encoding: gzip)' + : '无法读取请求体', + } }); } @@ -788,6 +793,32 @@ function getHeader(request, name) { } } +/** + * Read the request body as text, transparently gunzip-ing it when the + * client opted into request compression. `@rei-standard/amsg-client`'s + * `deliver({ compressRequest })` gzips a large request body and marks it + * with `X-Amsg-Request-Encoding: gzip` — a non-standard header, deliberately + * not `Content-Encoding`, so CDNs / proxies don't decode it out from under us. + * No marker ⇒ the body is read verbatim, so uncompressed requests are + * unaffected. + * + * @param {Request} request + * @returns {Promise} + */ +async function readRequestBodyText(request) { + if (getHeader(request, 'x-amsg-request-encoding').toLowerCase() !== 'gzip') { + return request.text(); + } + if (typeof DecompressionStream === 'undefined') { + const err = new Error('runtime lacks DecompressionStream for gzip request body'); + err.unsupportedEncoding = true; + throw err; + } + const compressed = await request.arrayBuffer(); + const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream('gzip')); + return new Response(stream).text(); +} + /** * Build the `Access-Control-Allow-*` headers applied to every response. * @@ -806,7 +837,7 @@ function buildCorsHeaders(cors) { const headers = { 'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Client-Token', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Client-Token, X-Amsg-Request-Encoding', 'Access-Control-Max-Age': '86400', }; if (allowOrigin !== '*') { diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 4ed7952..4ee33e9 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -21,6 +21,8 @@ import { buildContentPush, buildReasoningPush, buildErrorPush, + readReasoningContent, + stripReasoningTags, } from '@rei-standard/amsg-shared'; import { sendWebPush } from './webpush.js'; @@ -306,61 +308,6 @@ async function callLlmRaw(payload, fetchImpl, requireContent) { }; } -/** - * Read `choices[0].message.reasoning_content` as a non-empty trimmed - * string, or null when absent / empty. Many providers return an - * empty string instead of omitting the field — treat that the same - * as missing so we don't emit an empty ReasoningPush. - * - * @param {unknown} llmResponse - * @returns {string | null} - */ -function readReasoningContent(llmResponse) { - if (!llmResponse || typeof llmResponse !== 'object') return null; - 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(); - if (trimmed.length > 0) return trimmed; - } - - const content = message?.content; - if (typeof content === 'string') { - const match = content.match(REASONING_TAG_RE); - if (match) { - const trimmed = match[2].trim(); - if (trimmed.length > 0) return trimmed; - } - } - - 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: diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 2bb37fb..8f91f71 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -9,43 +9,13 @@ * scheduled-only fields here. */ -function isValidUrl(s) { - if (typeof s !== 'string') return false; - try { new URL(s); return true; } catch { return false; } -} +import { validateAvatarUrl } from '@rei-standard/amsg-shared'; const VALID_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']); -const AVATAR_URL_MAX_LENGTH = 2048; - -/** - * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically - * base64-encoded inline images) and anything longer than 2048 chars, both - * of which are the dominant trigger for downstream 413 / Web Push 4 KB - * payload errors. Returns an error message string, or null when valid. - * - * Mirrors amsg-server's `validateAvatarUrl` (kept in lockstep on purpose — - * both packages forward `avatarUrl` to the same SW push payload). - * - * @param {unknown} value - * @returns {string | null} - */ -export function validateAvatarUrl(value) { - if (value === undefined || value === null) return null; - if (typeof value !== 'string') { - return 'avatarUrl 必须是字符串'; - } - if (/^data:/i.test(value)) { - return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'; - } - if (value.length > AVATAR_URL_MAX_LENGTH) { - return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`; - } - if (!isValidUrl(value)) { - return 'avatarUrl 不是合法 URL'; - } - return null; -} +// `validateAvatarUrl` 现统一在 @rei-standard/amsg-shared(server / instant / +// client 共用一份规则)。此处重导出,保持 `createInstantHandler` 的公开导出不变。 +export { validateAvatarUrl }; function validateMessagesArray(messages) { if (!Array.isArray(messages) || messages.length === 0) { diff --git a/packages/rei-standard-amsg/instant/src/webpush.js b/packages/rei-standard-amsg/instant/src/webpush.js index 32c09ae..59dd48f 100644 --- a/packages/rei-standard-amsg/instant/src/webpush.js +++ b/packages/rei-standard-amsg/instant/src/webpush.js @@ -22,6 +22,7 @@ import { hmacSha256, randomBytes, } from './utils.js'; +import { normalizeVapidSubject } from '@rei-standard/amsg-shared'; // RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing). const KEY_INFO_PREFIX = utf8('WebPush: info\0'); @@ -330,12 +331,6 @@ export async function verifyVapidJwt(jwt, publicKey) { // ─── Helpers ─────────────────────────────────────────────────────────── -function normalizeVapidSubject(email) { - const trimmed = String(email || '').trim(); - if (!trimmed) return ''; - return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`; -} - function originOf(endpoint) { return new URL(endpoint).origin; } diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index 2de41b1..524d393 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -383,6 +383,60 @@ describe('createInstantHandler — request validation', () => { }); }); +// ─── Handler: gzip request body (compressRequest receiver support) ───── + +describe('createInstantHandler — gzip request body', () => { + // Mirror amsg-client's `deliver({ compressRequest })`: gzip the UTF-8 JSON + // and mark it with X-Amsg-Request-Encoding: gzip. + async function gzipString(text) { + const bytes = new TextEncoder().encode(text); + return new Uint8Array( + await new Response( + new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')) + ).arrayBuffer() + ); + } + + it('gunzips a body marked X-Amsg-Request-Encoding: gzip and processes it', async () => { + const router = llmRouter('compressed hello.'); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + + const gz = await gzipString(JSON.stringify(makeValidPayload())); + const req = new Request('http://localhost/instant', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'x-amsg-request-encoding': 'gzip', + }, + body: gz, + }); + + const res = await handler(req); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + assert.equal(router.pushCalls.length, 1); + }); + + it('rejects a body marked gzip that is not actually gzip with 400', async () => { + const handler = createInstantHandler({ vapid }); + const req = new Request('http://localhost/instant', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'x-amsg-request-encoding': 'gzip', + }, + body: 'not gzip bytes', + }); + const res = await handler(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); + }); +}); + // ─── Handler: clientToken weak auth ──────────────────────────────────── describe('createInstantHandler — clientToken', () => { diff --git a/packages/rei-standard-amsg/instant/test/url-and-cors.test.mjs b/packages/rei-standard-amsg/instant/test/url-and-cors.test.mjs index 8aaef3c..99f620a 100644 --- a/packages/rei-standard-amsg/instant/test/url-and-cors.test.mjs +++ b/packages/rei-standard-amsg/instant/test/url-and-cors.test.mjs @@ -143,6 +143,9 @@ describe('CORS preflight (OPTIONS)', () => { assert.match(res.headers.get('access-control-allow-headers') || '', /Content-Type/i); assert.match(res.headers.get('access-control-allow-headers') || '', /Authorization/i); assert.match(res.headers.get('access-control-allow-headers') || '', /X-Client-Token/i); + // Must allow the client's opt-in gzip marker, else cross-origin preflight + // blocks compressed `deliver({ compressRequest })` requests. + assert.match(res.headers.get('access-control-allow-headers') || '', /X-Amsg-Request-Encoding/i); assert.equal(res.headers.get('access-control-max-age'), '86400'); }); diff --git a/packages/rei-standard-amsg/server/README.md b/packages/rei-standard-amsg/server/README.md index 1a698d1..4d623a1 100644 --- a/packages/rei-standard-amsg/server/README.md +++ b/packages/rei-standard-amsg/server/README.md @@ -62,9 +62,17 @@ const rei = await createReiServer({ 当 `messageType` 为 `prompted` / `auto`,或 `instant` 使用 AI 配置时: -- `apiUrl` 必须是完整聊天端点(例如:`https://api.openai.com/v1/chat/completions`)。 -- SDK 会自动做最小规范化:去首尾空白、去路径尾部多余 `/`。 -- SDK **不会**自动补全 `/v1`、`/chat/completions` 等路径。 +- `apiUrl` 是聊天端点 URL(例如:`https://api.openai.com/v1/chat/completions`),必须能 `new URL(...)` 解析。 +- SDK 对 OpenAI 风格路径做**幂等**补全(去首尾空白、去尾部多余 `/` 后): + + | 输入 | 输出 | + |---|---| + | `https://api.openai.com`(裸域名) | `https://api.openai.com/v1/chat/completions` | + | `https://api.openai.com/v1`(版本段结尾) | `https://api.openai.com/v1/chat/completions`(不重复加 `/v1`) | + | `https://api.openai.com/v1/chat/completions` | 原样返回 | + | `https://api.anthropic.com/v1/messages`(其他自定义路径) | 原样返回,不猜 | + +- 规则幂等,传完整 URL 不会被改坏;代理路径很特殊时直接传完整 `…/chat/completions` 绕开补全。 - `maxTokens` 为可选字段:传了就映射为 `max_tokens`;不传则不指定(由上游模型默认策略决定)。 如果上游返回 `405 Method Not Allowed`,通常表示 `apiUrl` 指向了基础域名而非聊天端点,请优先检查配置值。 diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 5ad66d9..63c9cbd 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.2.0", + "@rei-standard/amsg-shared": "^0.2.0", "web-push": "^3.6.7", "@netlify/blobs": "^8.1.0" }, diff --git a/packages/rei-standard-amsg/server/src/server/index.js b/packages/rei-standard-amsg/server/src/server/index.js index 1985f86..6594928 100644 --- a/packages/rei-standard-amsg/server/src/server/index.js +++ b/packages/rei-standard-amsg/server/src/server/index.js @@ -31,12 +31,7 @@ import { createCancelMessageHandler } from './handlers/cancel-message.js'; import { createMessagesHandler } from './handlers/messages.js'; import { createTenantBlobStore } from './tenant/blob-store.js'; import { createTenantContextManager } from './tenant/context.js'; - -function normalizeVapidSubject(email) { - const trimmedEmail = String(email || '').trim(); - if (!trimmedEmail) return ''; - return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`; -} +import { normalizeVapidSubject } from '@rei-standard/amsg-shared'; /** * @typedef {Object} VapidConfig 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 3d01b4d..b6f9146 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 @@ -23,12 +23,19 @@ import { randomUUID } from 'crypto'; import { buildContentPush, buildReasoningPush, + readReasoningContent, + stripReasoningTags, } from '@rei-standard/amsg-shared'; import { decryptFromStorage, deriveUserEncryptionKey } from './encryption.js'; const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/; +// Pacing between consecutive Web Push deliveries (reasoning → content, and +// between content sentences) so the client renders a natural typing cadence. +// Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default. +const SLEEP_BETWEEN_MESSAGES_MS = 1500; + /** * Split a single chunk by one regex; on no-match return [chunk] so a later * regex in a cascade can still take a swing at it. @@ -48,11 +55,10 @@ function splitOnceByRegex(chunk, regex) { } /** - * Sentence splitter — kept byte-for-byte equivalent to - * `@rei-standard/amsg-instant`'s `splitMessageIntoSentences`. Server carries - * its own copy to avoid an architectural dependency on the instant package. - * Name matches the instant export on purpose so cross-package grep finds - * both copies; if you fix a bug here, fix it in the instant copy too. + * Sentence splitter for amsg-server's scheduled `splitPattern` feature + * (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its + * request-level `splitPattern`, so there is no instant counterpart to keep + * in lockstep. * * @param {string} messageContent * @param {string | string[] | null} [splitPattern=null] @@ -76,54 +82,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) { return chunks.length > 0 ? chunks : [messageContent]; } -/** - * Read `choices[0].message.reasoning_content` as a non-empty trimmed - * string, or null when absent / empty. Mirrors - * `amsg-instant/src/message-processor.js#readReasoningContent`. - * - * @param {unknown} llmResponse - * @returns {string | null} - */ -function readReasoningContent(llmResponse) { - if (!llmResponse || typeof llmResponse !== 'object') return null; - 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(); - if (trimmed.length > 0) return trimmed; - } - - const content = message?.content; - if (typeof content === 'string') { - const match = content.match(REASONING_TAG_RE); - if (match) { - const trimmed = match[2].trim(); - if (trimmed.length > 0) return trimmed; - } - } - - 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). @@ -237,7 +195,7 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { metadata, }); await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush)); - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS)); } for (let i = 0; i < messages.length; i++) { @@ -261,7 +219,7 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush)); if (i < messages.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS)); } } diff --git a/packages/rei-standard-amsg/server/src/server/lib/validation.js b/packages/rei-standard-amsg/server/src/server/lib/validation.js index b0392ad..64db61a 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/validation.js +++ b/packages/rei-standard-amsg/server/src/server/lib/validation.js @@ -3,6 +3,8 @@ * ReiStandard SDK v2.0.1 */ +import { validateAvatarUrl } from '@rei-standard/amsg-shared'; + /** * Validate ISO 8601 date string. * @param {string} dateString @@ -49,43 +51,17 @@ export function isValidUUIDv4(uuid) { const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']); -const AVATAR_URL_MAX_LENGTH = 2048; - -/** - * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically - * base64-encoded inline images) and anything longer than 2048 chars, both - * of which are the dominant trigger for downstream 413 / Web Push 4 KB - * payload errors. Returns an error message string, or null when valid. - * - * Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep - * on purpose — both packages forward `avatarUrl` to the same SW push payload). - * - * @param {unknown} value - * @returns {string | null} - */ -export function validateAvatarUrl(value) { - if (value === undefined || value === null) return null; - if (typeof value !== 'string') { - return 'avatarUrl 必须是字符串'; - } - if (/^data:/i.test(value)) { - return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'; - } - if (value.length > AVATAR_URL_MAX_LENGTH) { - return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`; - } - if (!isValidUrl(value)) { - return 'avatarUrl 不是合法 URL'; - } - return null; -} +// `validateAvatarUrl` 与其 2048 字符上限现统一在 @rei-standard/amsg-shared, +// server / instant / client 共用一份规则。此处重导出,保持本模块及 +// `createReiServer` 的公开导出不变。 +export { validateAvatarUrl }; const SPLIT_PATTERN_MAX_LENGTH = 200; const SPLIT_PATTERN_MAX_ITEMS = 10; /** - * Validate the optional `splitPattern` field. Mirrors - * @rei-standard/amsg-instant's `validateSplitPattern` (kept in lockstep). + * Validate the optional `splitPattern` field (amsg-server scheduled tasks + * only; amsg-instant 0.8.0 dropped its request-level `splitPattern`). * Accepts `string`, `string[]`, or absent/null. Returns an error message * string, or null when valid. * diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index 2580d5e..9ddaadb 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,4 +1,4 @@ -# @rei-standard/amsg-shared +# Changelog — @rei-standard/amsg-shared ## 0.2.0 — Notification silent support diff --git a/packages/rei-standard-amsg/shared/README.md b/packages/rei-standard-amsg/shared/README.md index 404b6a3..8da637a 100644 --- a/packages/rei-standard-amsg/shared/README.md +++ b/packages/rei-standard-amsg/shared/README.md @@ -1,7 +1,7 @@ # @rei-standard/amsg-shared -Lowest layer of the ReiStandard Active Messaging ecosystem. Defines -the **three-axis push contract** that `amsg-instant`, `amsg-server`, +Lowest layer of the ReiStandard Active Messaging stack. Defines +the **push schema** that `amsg-instant`, `amsg-server`, `amsg-sw`, and `amsg-client` all conform to. Zero runtime deps. Does **not** depend on any other amsg package — @@ -9,9 +9,9 @@ every other amsg sub-package depends on this one, never the reverse. --- -## Three axes +## Push schema -A single push is described by three orthogonal axes: +A single push is described by three independent dimensions: | Axis | Field | Values | Defined by | |----------------|-------------------|-------------------------------------------------------|--------------------| @@ -22,7 +22,7 @@ A single push is described by three orthogonal axes: `messageType` answers **how this push was produced** (one-shot `instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully `auto`-generated cadence). `messageKind` answers **what it carries**. -The two are intentionally orthogonal: any `messageType` can carry any +The two are intentionally independent: any `messageType` can carry any `messageKind`. There is also `source: 'instant' | 'scheduled'` — the **routing diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js index 9ebc308..2972d03 100644 --- a/packages/rei-standard-amsg/shared/src/index.js +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -153,24 +153,24 @@ export const PUSH_SOURCE = Object.freeze({ * out of the upstream response into its own push. Emitted **before** * the matching {@link ContentPush} burst when present and non-empty. * - * Reasoning carries two orthogonal "multi-part" axes, both optional — - * they are *omitted* when the part count is 1 so the wire stays - * byte-for-byte compatible with single-shot ReasoningPush callers: + * Reasoning carries two optional "multi-part" axes, both *omitted* when + * the part count is 1 so the wire stays byte-for-byte compatible with + * single-shot callers. The type reserves them for forward compatibility; + * current producers emit a single ReasoningPush and set neither — oversized + * reasoning rides the generic multipart transport, not a reasoning-only + * chunk format. * - * - `messageIndex` / `totalMessages` — set when a semantic - * splitter (`reasoningSplitPattern` in amsg-instant) has cut the - * reasoning into multiple sentences for typing-bubble UX. + * - `messageIndex` / `totalMessages` — a 1-based part index when a producer + * splits reasoning into multiple sentences for typing-bubble UX. * - * - `chunkIndex` / `totalChunks` — set when a single segment was - * too large for the Web Push payload limit and the producer had - * to slice it across multiple pushes at UTF-8 byte boundaries. - * Transport-only; SW reassembles the original `reasoningContent` - * by sorting on `chunkIndex` within a `(sessionId, messageIndex)` - * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge - * splitter helper. + * - `chunkIndex` / `totalChunks` — transport-only slicing when a single + * segment exceeds the Web Push payload limit; SW would reassemble the + * original `reasoningContent` by sorting on `chunkIndex` within a + * `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes` + * for the safe-edge splitter helper. * - * Both axes can coexist on the same push when a sentence-split - * segment is itself oversized. + * Both axes can coexist on the same push when a sentence-split segment is + * itself oversized. * * @typedef {AmsgPushCommon & { * messageKind: 'reasoning', @@ -686,3 +686,125 @@ export function concatBytes(...chunks) { } return out; } + +// ─── Validation & normalization helpers ───────────────────────────────── +// Shared by amsg-server / amsg-instant / amsg-client so the same rules live +// in exactly one place. All pure (no side effects). + +/** + * True when `value` parses as an absolute URL. + * @param {unknown} value + * @returns {boolean} + */ +export function isValidUrl(value) { + if (typeof value !== 'string') return false; + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** Max accepted `avatarUrl` length, in characters. */ +export const AVATAR_URL_MAX_LENGTH = 2048; + +/** + * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically + * base64-encoded inline images) and anything longer than + * {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for + * downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't + * parse as a URL. Returns an error message string, or null when valid. + * + * Pure: callers decide how to act on a non-null result (amsg-server / + * amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2). + * + * @param {unknown} value + * @returns {string | null} + */ +export function validateAvatarUrl(value) { + if (value === undefined || value === null) return null; + if (typeof value !== 'string') { + return 'avatarUrl 必须是字符串'; + } + if (/^data:/i.test(value)) { + return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'; + } + if (value.length > AVATAR_URL_MAX_LENGTH) { + return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`; + } + if (!isValidUrl(value)) { + return 'avatarUrl 不是合法 URL'; + } + return null; +} + +/** + * Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a + * `mailto:` address or an `http(s):` URL; a bare contact like + * `you@example.com` is prefixed with `mailto:`. An already-prefixed + * `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`. + * + * @param {unknown} email + * @returns {string} + */ +export function normalizeVapidSubject(email) { + const trimmed = String(email || '').trim(); + if (!trimmed) return ''; + return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`; +} + +/** + * Matches `` / `` / `` + * spans (case-insensitive, lazy multi-line). The plain form captures the inner + * text in group 2; the `_G` form is the global stripper. + */ +const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i; +const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi; + +/** + * Read `choices[0].message.reasoning_content` as a non-empty trimmed string, + * or null when absent / empty. Falls back to the first `` span inside + * `message.content` when a provider inlines reasoning there. Many providers + * return an empty string instead of omitting the field — treated the same as + * missing so callers don't emit an empty ReasoningPush. + * + * @param {unknown} llmResponse + * @returns {string | null} + */ +export function readReasoningContent(llmResponse) { + if (!llmResponse || typeof llmResponse !== 'object') return null; + 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(); + if (trimmed.length > 0) return trimmed; + } + + const content = message?.content; + if (typeof content === 'string') { + const match = content.match(REASONING_TAG_RE); + if (match) { + const trimmed = match[2].trim(); + if (trimmed.length > 0) return trimmed; + } + } + + return null; +} + +/** + * Drop any `` / `` / `` spans from a user-facing + * content string, so private chain-of-thought leaking through `message.content` + * does not also ship inside the ContentPush burst. + * + * @param {string} content + * @returns {string} + */ +export function stripReasoningTags(content) { + if (typeof content !== 'string' || !content.includes('<')) return content; + return content.replace(REASONING_TAG_RE_G, '').trim(); +} diff --git a/packages/rei-standard-amsg/shared/test/helpers.test.mjs b/packages/rei-standard-amsg/shared/test/helpers.test.mjs new file mode 100644 index 0000000..a763a5a --- /dev/null +++ b/packages/rei-standard-amsg/shared/test/helpers.test.mjs @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + isValidUrl, + AVATAR_URL_MAX_LENGTH, + validateAvatarUrl, + normalizeVapidSubject, + readReasoningContent, + stripReasoningTags, +} from '../src/index.js'; + +// ─── validateAvatarUrl ────────────────────────────────────────────────── + +test('validateAvatarUrl: absent value is OK (null)', () => { + assert.equal(validateAvatarUrl(undefined), null); + assert.equal(validateAvatarUrl(null), null); +}); + +test('validateAvatarUrl: a normal https URL passes', () => { + assert.equal(validateAvatarUrl('https://cdn.example.com/a.png'), null); +}); + +test('validateAvatarUrl: non-string rejected', () => { + assert.match(validateAvatarUrl(123), /必须是字符串/); +}); + +test('validateAvatarUrl: data: URI rejected', () => { + assert.match(validateAvatarUrl('data:image/png;base64,AAAA'), /data:/); +}); + +test('validateAvatarUrl: over-length rejected', () => { + const long = 'https://e.com/' + 'a'.repeat(AVATAR_URL_MAX_LENGTH); + assert.match(validateAvatarUrl(long), /超过/); +}); + +test('validateAvatarUrl: malformed non-data URL rejected (the client-alignment case)', () => { + // `new URL('foo.com/a.png')` throws (no scheme) — server/instant always + // rejected this; client now does too via the shared validator. + assert.match(validateAvatarUrl('foo.com/a.png'), /不是合法 URL/); + assert.match(validateAvatarUrl('not a url'), /不是合法 URL/); +}); + +test('isValidUrl: absolute URL true, scheme-less false', () => { + assert.equal(isValidUrl('https://e.com'), true); + assert.equal(isValidUrl('e.com/x'), false); + assert.equal(isValidUrl(123), false); +}); + +// ─── normalizeVapidSubject ────────────────────────────────────────────── + +test('normalizeVapidSubject: bare email gets mailto: prefix', () => { + assert.equal(normalizeVapidSubject('you@example.com'), 'mailto:you@example.com'); +}); + +test('normalizeVapidSubject: existing mailto: kept', () => { + assert.equal(normalizeVapidSubject('mailto:you@example.com'), 'mailto:you@example.com'); +}); + +test('normalizeVapidSubject: https: subject kept as-is (regression guard)', () => { + // RFC 8292 allows an https: subject. The previous server-only copy only + // matched /^mailto:/ and would mangle this into `mailto:https://...`. + assert.equal( + normalizeVapidSubject('https://example.com/contact'), + 'https://example.com/contact', + ); + assert.equal(normalizeVapidSubject('http://example.com/c'), 'http://example.com/c'); +}); + +test('normalizeVapidSubject: blank → empty string', () => { + assert.equal(normalizeVapidSubject(''), ''); + assert.equal(normalizeVapidSubject(' '), ''); + assert.equal(normalizeVapidSubject(undefined), ''); +}); + +// ─── readReasoningContent / stripReasoningTags ────────────────────────── + +test('readReasoningContent: reads reasoning_content', () => { + const r = readReasoningContent({ choices: [{ message: { reasoning_content: ' 思考中 ' } }] }); + assert.equal(r, '思考中'); +}); + +test('readReasoningContent: empty reasoning_content → null', () => { + assert.equal(readReasoningContent({ choices: [{ message: { reasoning_content: ' ' } }] }), null); + assert.equal(readReasoningContent({}), null); + assert.equal(readReasoningContent(null), null); +}); + +test('readReasoningContent: falls back to span in content', () => { + const r = readReasoningContent({ choices: [{ message: { content: '盘算一下你好' } }] }); + assert.equal(r, '盘算一下'); +}); + +test('stripReasoningTags: removes span (privacy regression guard)', () => { + // Private chain-of-thought must never ride along inside the user-facing text. + assert.equal(stripReasoningTags('secret plan你好'), '你好'); + assert.equal(stripReasoningTags('axb'), 'ab'); + assert.equal(stripReasoningTags('mno'), 'n'); +}); + +test('stripReasoningTags: content without tags is untouched', () => { + assert.equal(stripReasoningTags('plain text'), 'plain text'); + assert.equal(stripReasoningTags('no angle brackets here'), 'no angle brackets here'); +}); diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 618a5c5..b652cb5 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -1,6 +1,6 @@ # @rei-standard/amsg-sw -`@rei-standard/amsg-sw` 是 ReiStandard 主动消息标准的 Service Worker 插件包,目标是让推送展示和离线重试“开箱即用”。 +`@rei-standard/amsg-sw` 是 ReiStandard 主动消息标准的 Service Worker 插件包,负责推送展示和离线重试,装上就能用。 ## v2.1.0 — 按 kind 分发的客户端事件 @@ -282,7 +282,7 @@ dedupe 库、queue / multipart 库都把 IndexedDB 连接缓存复用。浏览 ## 功能概览 -- 处理 `push` 事件:按 `messageKind` 三轴 schema 分发到客户端 + 仅 `content` 走 `showNotification` +- 处理 `push` 事件:按 `messageKind` 分发到客户端 + 仅 `content` 走 `showNotification` - 透明重组 `_multipart` transport:应用层只收到完整原始 payload - 处理 `message` 事件:支持离线请求入队与主动冲刷队列 - 处理 `sync` 事件:在网络恢复后自动重试队列请求 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index b03c0c6..4c341d0 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.2.0" + "@rei-standard/amsg-shared": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 71f92e0..38939e4 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -964,7 +964,7 @@ function normalizeMultipartChunk(payload, options) { let chunkBytes; try { chunkBytes = base64UrlToBytes(payload.chunk); - } catch (_error) { console.error("RESTORE ERROR", _error); + } catch (_error) { return null; } @@ -1021,7 +1021,7 @@ async function maybeCleanupMultipart(sw, ctx) { ctx.setLastMultipartCleanupAt(now); try { await cleanupMultipartStores(sw, now); - } catch (_error) { console.error("RESTORE ERROR", _error); + } catch (_error) { // Cleanup is observability/housekeeping; never block a fresh push. } } @@ -1167,7 +1167,7 @@ function normalizeRequestBody(bodyInput) { try { return JSON.stringify(bodyInput); - } catch (_error) { console.error("RESTORE ERROR", _error); + } catch (_error) { throw new Error('[rei-standard-amsg-sw] request body is not serializable'); } } @@ -1201,7 +1201,7 @@ async function trySendQueuedRequest(queuedRequest) { } return false; - } catch (_error) { console.error("RESTORE ERROR", _error); + } catch (_error) { return false; } } @@ -1212,7 +1212,7 @@ async function registerFlushSync(sw) { try { await syncManager.register(REI_SW_SYNC_TAG); - } catch (_error) { console.error("RESTORE ERROR", _error); + } catch (_error) { // Ignore unsupported/denied sync registration and rely on manual flush. } } @@ -1318,10 +1318,6 @@ function deleteMultipartPending(id) { return deleteStoreRecord(REI_SW_MULTIPART_STORE, id); } -function listMultipartPending() { - return listStoreRecords(REI_SW_MULTIPART_STORE); -} - function readMultipartDone(id) { return readStoreRecord(REI_SW_MULTIPART_DONE_STORE, id); } @@ -1334,10 +1330,6 @@ function deleteMultipartDone(id) { return deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id); } -function listMultipartDone() { - return listStoreRecords(REI_SW_MULTIPART_DONE_STORE); -} - async function hasMultipartChunk(id_index) { if (!hasIndexedDB()) return memoryMultipartChunks.has(id_index); return withDatabaseStore(REI_SW_MULTIPART_CHUNK_STORE, 'readonly', (store, resolve, reject) => { @@ -1429,18 +1421,6 @@ async function deleteStoreRecord(storeName, id) { }); } -async function listStoreRecords(storeName) { - if (!hasIndexedDB()) { - return Array.from(memoryStoreFor(storeName).values()).map(cloneRecord); - } - - return withDatabaseStore(storeName, 'readonly', (store, resolve, reject) => { - const request = store.getAll(); - request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []); - request.onerror = () => reject(request.error || new Error(`Failed to list ${storeName}`)); - }); -} - /** * True when an error means the IndexedDB connection we just used is * closing/closed — i.e. the browser force-closed it (backing-store error, diff --git a/scripts/publish-workspaces.mjs b/scripts/publish-workspaces.mjs deleted file mode 100644 index 589a0e5..0000000 --- a/scripts/publish-workspaces.mjs +++ /dev/null @@ -1,253 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const rootDir = process.cwd(); -const dryRun = process.argv.includes('--dry-run'); -const useProvenance = process.env.NPM_PUBLISH_PROVENANCE !== 'false'; -const publishTagFromEnv = (process.env.NPM_PUBLISH_TAG || '').trim(); - -/** - * Parse the triggering git tag and, if it identifies a single package, - * return that package's npm name (e.g. `@rei-standard/amsg-instant`). - * - * Background — race with parallel tag pushes: - * The Release workflow fires on every `rei-standard-amsg-*@*` tag push. - * If multiple per-package tags are pushed at once (e.g. coordinated - * instant + server + client release), N parallel workflow runs start, - * and each iterates ALL public workspaces. They all see "version not - * yet on npm", all try to publish, and N-1 of them lose a race to - * `403 You cannot publish over the previously published versions`. - * - * Filtering by the triggering tag means each run touches exactly one - * package — no overlap, no race. `workflow_dispatch` and local CLI - * invocations fall through to a sweep-all (every workspace that isn't - * yet on npm), useful for manual recovery if a tag run died mid-batch. - * - * Pattern (matches the repo's tagging convention): - * `rei-standard-amsg-instant@0.5.0` → `@rei-standard/amsg-instant` - * `rei-standard-amsg-server@2.2.0` → `@rei-standard/amsg-server` - * `rei-standard-amsg-client@2.2.1` → `@rei-standard/amsg-client` - * undefined / manual / unknown shape → null (sweep all) - * - * @param {string} ref - Typically `process.env.GITHUB_REF` (e.g. - * `refs/tags/rei-standard-amsg-instant@0.5.0`). - * Also accepts the bare tag name. - * @returns {string | null} - */ -function resolveTargetPackageFromTag(ref) { - if (!ref) return null; - const tagName = ref.startsWith('refs/tags/') ? ref.slice('refs/tags/'.length) : ref; - const match = tagName.match(/^rei-standard-amsg-([^@/]+)@/); - if (!match) return null; - return `@rei-standard/amsg-${match[1]}`; -} - -const targetPackage = resolveTargetPackageFromTag(process.env.GITHUB_REF); - -function isPrereleaseVersion(version) { - return /-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(version); -} - -function resolvePublishTag(version) { - if (publishTagFromEnv) return publishTagFromEnv; - if (isPrereleaseVersion(version)) return 'next'; - return ''; -} - -function readJson(filePath) { - const raw = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(raw); -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd || rootDir, - encoding: 'utf8', - stdio: options.stdio || 'pipe' - }); - - if (result.status !== 0) { - const stderr = (result.stderr || '').trim(); - const stdout = (result.stdout || '').trim(); - const details = [stderr, stdout].filter(Boolean).join('\n'); - throw new Error(`Command failed: ${command} ${args.join(' ')}\n${details}`); - } - - return result; -} - -function pathExists(filePath) { - return fs.existsSync(filePath); -} - -function resolveWorkspaceDirs(workspaces) { - const dirs = new Set(); - - for (const pattern of workspaces) { - if (!pattern || typeof pattern !== 'string') continue; - - if (pattern.endsWith('/*')) { - const baseDir = path.join(rootDir, pattern.slice(0, -2)); - if (!pathExists(baseDir)) continue; - - const entries = fs.readdirSync(baseDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - dirs.add(path.join(baseDir, entry.name)); - } - - continue; - } - - const fullPath = path.join(rootDir, pattern); - if (!pathExists(fullPath)) continue; - dirs.add(fullPath); - } - - return Array.from(dirs); -} - -function collectExportTargets(value, targets) { - if (typeof value === 'string') { - if (value.startsWith('./')) targets.push(value); - return; - } - - if (!value || typeof value !== 'object') return; - - for (const nestedValue of Object.values(value)) { - collectExportTargets(nestedValue, targets); - } -} - -function ensureBuildArtifacts(pkgDir, pkg) { - const distDir = path.join(pkgDir, 'dist'); - - if (Array.isArray(pkg.files) && pkg.files.includes('dist')) { - if (!pathExists(distDir)) { - throw new Error(`[publish] Missing dist directory for ${pkg.name}. Run build first.`); - } - - const distEntries = fs.readdirSync(distDir); - if (distEntries.length === 0) { - throw new Error(`[publish] Empty dist directory for ${pkg.name}. Run build first.`); - } - } - - const exportTargets = []; - collectExportTargets(pkg.exports, exportTargets); - - for (const target of exportTargets) { - const absoluteTarget = path.join(pkgDir, target); - if (!pathExists(absoluteTarget)) { - throw new Error(`[publish] Missing export target for ${pkg.name}: ${target}`); - } - } -} - -function isVersionPublished(name, version) { - const spec = `${name}@${version}`; - const result = spawnSync( - 'npm', - ['view', spec, 'version', '--registry', 'https://registry.npmjs.org'], - { encoding: 'utf8' } - ); - - if (result.status === 0) { - const publishedVersion = (result.stdout || '').trim(); - return publishedVersion === version; - } - - const combinedOutput = `${result.stdout || ''}\n${result.stderr || ''}`; - if (/E404|404 Not Found|No match found for version/i.test(combinedOutput)) { - return false; - } - - throw new Error( - `[publish] Failed to query npm for ${spec}:\n${combinedOutput.trim()}` - ); -} - -function publishWorkspace(pkgDir, pkg) { - const npmArgs = ['publish', '--access', 'public']; - const publishTag = resolvePublishTag(pkg.version); - - if (publishTag) { - npmArgs.push('--tag', publishTag); - } - - if (useProvenance) { - npmArgs.push('--provenance'); - } - - if (dryRun) { - npmArgs.push('--dry-run'); - } - - const tagLabel = publishTag ? ` (tag: ${publishTag})` : ''; - console.log(`[publish] Publishing ${pkg.name}@${pkg.version} from ${path.relative(rootDir, pkgDir)}${tagLabel}`); - run('npm', npmArgs, { cwd: pkgDir, stdio: 'inherit' }); -} - -function main() { - const rootPkg = readJson(path.join(rootDir, 'package.json')); - const workspacePatterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []; - const workspaceDirs = resolveWorkspaceDirs(workspacePatterns); - - const publishable = []; - - for (const workspaceDir of workspaceDirs) { - const packageJsonPath = path.join(workspaceDir, 'package.json'); - if (!pathExists(packageJsonPath)) continue; - - const pkg = readJson(packageJsonPath); - if (pkg.private) continue; - - if (!pkg.name || !pkg.version) { - throw new Error(`[publish] Invalid package manifest at ${packageJsonPath}`); - } - - publishable.push({ dir: workspaceDir, pkg }); - } - - if (publishable.length === 0) { - console.log('[publish] No public workspaces found. Nothing to publish.'); - return; - } - - let queue = publishable; - if (targetPackage) { - queue = publishable.filter((entry) => entry.pkg.name === targetPackage); - if (queue.length === 0) { - // The triggering tag's package name didn't match any workspace — - // either the tag was for something that lives outside the workspace - // root or a typo. Failing loud beats silently publishing nothing. - throw new Error( - `[publish] Tag-derived package "${targetPackage}" matches no workspace. ` + - `Triggering ref: ${process.env.GITHUB_REF || '(none)'}` - ); - } - console.log(`[publish] Tag-scoped run: only publishing ${targetPackage} (from ${process.env.GITHUB_REF}).`); - } else { - console.log('[publish] Sweep-all run: iterating every public workspace (no per-package tag detected).'); - } - - for (const { dir, pkg } of queue) { - ensureBuildArtifacts(dir, pkg); - - if (isVersionPublished(pkg.name, pkg.version)) { - console.log(`[publish] Skip ${pkg.name}@${pkg.version} (already published).`); - continue; - } - - publishWorkspace(dir, pkg); - } -} - -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -} diff --git a/standards/active-messaging-api.md b/standards/active-messaging-api.md index ff70e36..58dd3f6 100644 --- a/standards/active-messaging-api.md +++ b/standards/active-messaging-api.md @@ -4,9 +4,9 @@ > > 版本日期:2026-05-19 > -> 对齐实现(稳定版):`@rei-standard/amsg-shared` 0.1.0、`@rei-standard/amsg-server` 2.4.0、`@rei-standard/amsg-instant` 0.8.0、`@rei-standard/amsg-client` 2.3.0、`@rei-standard/amsg-sw` 2.1.0。 +> 对齐实现(稳定版):`@rei-standard/amsg-shared` 0.2.0、`@rei-standard/amsg-server` 2.5.2、`@rei-standard/amsg-instant` 0.9.1、`@rei-standard/amsg-client` 2.7.0、`@rei-standard/amsg-sw` 2.3.1。 > -> 本轮是一次跨包协调的 minor 升级:push wire shape 统一到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器),同时移除旧的 `{ type: 'error', code: '...' }` 错误信封。包间依赖一律使用精确版本(不带 `^`),所有 `dependencies` 字段都钉死在对应的稳定版本。 +> 本轮是一次跨包协调的 minor 升级:push wire shape 统一到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器),同时移除旧的 `{ type: 'error', code: '...' }` 错误信封。上层包对 `@rei-standard/amsg-shared` 用脱字号区间(`^0.2.0`):在 0.x 上只放行同一 minor 内的补丁,shared 出补丁时消费者自动跟随,shared 升 minor 则需消费者显式升级区间。 ## 1. 目标与范围 @@ -153,11 +153,22 @@ export const config = { | `POST` | `/api/v1/send-notifications` | cron 触发发送 | `cronToken` | | `POST` | `/api/v1/send-notifications-scheduled` | 每分钟聚合调度(推荐,可选) | 平台内部调度调用 | +上表是 `@rei-standard/amsg-server` 的端点。`@rei-standard/amsg-instant` 是另一套无状态 worker,自带 `/instant`(一次性即时推送)与 `/continue`(agentic-loop 工具回执续跑,仅当 handler 配了 `onLLMOutput` 时可用)两个端点,鉴权用可选的 client token,详见 [`amsg-instant` README](../packages/rei-standard-amsg/instant/README.md)。本规范正文提到 `/continue` 时即指这里。 + ### 6.1 AI 消息字段约束 当消息使用 AI(`messageType=prompted/auto`,或 `instant` 提供完整 AI 配置)时,下述字段约束适用于 `schedule-message`、`update-message`、`amsg-instant` handler;其中 `splitPattern` 仅适用于 `amsg-server` 的调度任务,`amsg-instant` 0.8.0 的替代方式见本节末尾。 -**`apiUrl`(必填,字符串)** — 完整聊天端点 URL(例:`https://api.openai.com/v1/chat/completions`)。实现方可做最小规范化(去首尾空白、去路径尾部多余 `/`),但**不应**自动补全版本路径(`/v1`)或聊天路径(`/chat/completions`)。若上游返回 `405 Method Not Allowed`,应优先判定为 URL 指向错误端点。 +**`apiUrl`(必填,字符串)** — 聊天端点 URL。必须能 `new URL(...)` 解析,否则抛错(缺失 / 空串 / 非法 URL)。实现方按下表对 OpenAI 风格路径做**幂等**补全(跑两次 = 跑一次,传完整 URL 不会被改坏),先去首尾空白、去路径尾部多余 `/`,再按路径形态决定: + +| 输入路径形态 | 处理 | +|---|---| +| 已以 `/chat/completions` 结尾 | 原样保留 | +| 裸域名(无路径或仅 `/`) | 补成 `/v1/chat/completions` | +| 以版本段结尾(`/v1`、`/v2`…) | 仅补 `/chat/completions`,**不重复加 `/v1`** | +| 其它自定义路径(如 `/v1/messages`、`/openai/api/foo`) | 原样保留,不猜 | + +query string 原样保留。要绕开补全(代理路径很特殊时),直接传完整 `…/chat/completions` 即可。若上游返回 `405 Method Not Allowed`,应优先判定为 URL 指向错误端点。`@rei-standard/amsg-server` 与 `@rei-standard/amsg-instant` 各自带一份 `normalizeAiApiUrl`,规则与测试保持一致。 **`completePrompt` 与 `messages`(互斥二选一)** @@ -255,9 +266,9 @@ createInstantHandler({ 预校验工具:`validateAvatarUrl(value)`(`amsg-server` 与 `amsg-instant` 同步导出)—— 返回错误描述字符串或 `null`,**纯函数**,不副作用;上层调用方按软清空策略处理。 -### 6.3 推送 wire shape:三轴判别联合 +### 6.3 推送 wire shape:`AmsgPush` 判别联合 -自 v2.4 起,所有 amsg 包推出的 Web Push payload 统一遵循 `@rei-standard/amsg-shared` 定义的 `AmsgPush` 判别联合。每条推送由三个**正交**的维度描述: +自 v2.4 起,所有 amsg 包推出的 Web Push payload 统一遵循 `@rei-standard/amsg-shared` 定义的 `AmsgPush` 判别联合。每条推送由三个互不影响的维度描述: | 轴 | 字段 | 取值 | 由谁定 | |---|---|---|---| @@ -352,6 +363,27 @@ LLM 驱动路径(`amsg-instant` 的 legacy 路径与 agentic-loop 钩子路径 5. **非 LLM 路径不触发**:`fixed` 任务与 `userMessage` 显式路径不产 LLM 响应,自然不发 ReasoningPush。 6. **`messageIndex` / `totalMessages` 不带**:ReasoningPush 不参与分句 burst 计数;server 端的 `messagesSent` 也只数 ContentPush。 +### 6.5 `schedule-message` 的 `instant` 同步发送 + +`/api/v1/schedule-message` 收到 `messageType: 'instant'` 时,server 在请求内**同步**走完「建任务 → 按 UUID 处理 → 删任务」,不进 cron 队列: + +- 发送成功 → HTTP `200`,`data.status = 'sent'`,附 `messagesSent` / `sentAt` / `retriesUsed`。 +- 发送失败 → HTTP `500`,`error.code = MESSAGE_SEND_FAILED`,底层处理错误放在 `error.details`(见 §9)。 +- 其余 `messageType`(`fixed` / `prompted` / `auto`)→ HTTP `201` 的调度响应,任务入库等 cron 触发。 + +这条 in-server instant 路径要数据库,任务先落库再处理,投递不绑请求连接(客户端断开仍会跑完、可重试)。它与无状态的 `@rei-standard/amsg-instant` worker 是两条都正式支持的路径,按各自特点选用,详见 [`amsg-server` README](../packages/rei-standard-amsg/server/README.md)。 + +### 6.6 投递裁决(delivery adjudication) + +`@rei-standard/amsg-instant` 0.9.0+ 默认强制开启 Web Push always-on backup:同一条业务消息**总是**同时走 SSE 流式直送 + Web Push 备份两条通道,由 SW 端按 `messageId` 去重收敛(见 [Service Worker 规范](./service-worker-specification.md))。 + +因此下面两个信号都**不**代表送达: + +- transport 成功(HTTP `200` / SSE enqueue 成功)只说明「发出去了」,不等于消费者收到(push backup 仍可能没到,或反过来)。 +- SSE 这条流断开 / reject 也不等于没送达(push backup 可能已到,常见于 iOS 把后台 fetch 杀掉)。 + +投递契约:transport 的成败只用来收紧延迟,**不用来判送达**;送达由调用方提供的一条 out-of-band「观察通道」裁决(一个等业务上「真到了」才 resolve 的 Promise)。`@rei-standard/amsg-client` 的 `deliver()` 实现了这套裁决,返回 `delivered` / `cancelled` / `timeout` / `send-failed` / `completed-unconfirmed` 五种 outcome,完整 API 见 [`amsg-client` README](../packages/rei-standard-amsg/client/README.md)。 + ## 7. 一体化初始化接口 ### 7.1 请求 @@ -436,25 +468,52 @@ Body: ## 9. 错误码 +错误响应体形如 `{ success: false, error: { code, message, details? } }`。下表是直接返回给 API 调用方的**顶层** `error.code`。 + | HTTP | code | 含义 | |---|---|---| -| 400 | `INVALID_JSON` | 请求体 JSON 不合法 | -| 400 | `INVALID_PARAMETERS` | 参数缺失或格式非法(`schedule-message` 与 `init-tenant` 路径) | +| 400 | `INVALID_JSON` | 请求体不是有效 JSON | +| 400 | `INVALID_REQUEST_BODY` | 请求体不是 JSON 对象 | +| 400 | `INVALID_ENCRYPTED_PAYLOAD` | 加密信封格式错误(缺 `iv` / `authTag` / `encryptedData`) | +| 400 | `ENCRYPTION_REQUIRED` | 未按规范提交加密请求体 | +| 400 | `UNSUPPORTED_ENCRYPTION_VERSION` | 不支持的加密版本 | +| 400 | `DECRYPTION_FAILED` | 请求体解密失败(`schedule-message` / `update-message`) | +| 400 | `INVALID_PARAMETERS` | 参数缺失或格式非法(`init-tenant` / `schedule-message` / `messages` 查询参数) | | 400 | `INVALID_UPDATE_DATA` | `update-message` 字段非法(含 §6.1 / §6.2 校验) | -| 400 | `INVALID_PAYLOAD_FORMAT` | `amsg-instant` payload 格式非法(含 §6.1 / §6.2 校验) | +| 400 | `INVALID_PAYLOAD_FORMAT` | 解密后数据非 JSON 对象;或 `amsg-instant` payload 格式非法(含 §6.1 / §6.2 校验) | | 400 | `INVALID_DRIVER` | 不支持的数据库驱动 | | 400 | `INVALID_DATABASE_URL` | `databaseUrl` 缺失或为空 | +| 400 | `INVALID_TENANT_ID` | `init-tenant` 传入的 `tenantId` 非 UUID v4 | | 400 | `INVALID_USER_ID_FORMAT` | `X-User-Id` 非 UUID v4 | -| 400 | `ENCRYPTION_REQUIRED` | 未按规范提交加密请求体 | -| 400 | `UNSUPPORTED_ENCRYPTION_VERSION` | 不支持的加密版本 | +| 400 | `USER_ID_REQUIRED` | 缺少 `X-User-Id` 请求头 | +| 400 | `TASK_ID_REQUIRED` | 缺少任务 id(`cancel-message` / `update-message` 的 `?id=`) | | 401 | `INVALID_INIT_AUTH` | 初始化鉴权失败(仅当服务端启用 `INIT_SECRET` 时) | -| 401 | `INVALID_TENANT_AUTH` | 租户 token 无效或缺失 | +| 401 | `INVALID_TENANT_AUTH` | 租户 token 无效、过期、类型不匹配或缺失 | | 404 | `TASK_NOT_FOUND` | 任务不存在 | -| 409 | `TASK_UUID_CONFLICT` | UUID 冲突 | +| 409 | `TASK_UUID_CONFLICT` | 创建任务时 UUID 冲突 | | 409 | `TASK_ALREADY_COMPLETED` | 任务已结束,不可更新 | +| 409 | `UPDATE_CONFLICT` | 任务更新失败(可能已被并发修改或删除) | +| 409 | `TENANT_ALREADY_INITIALIZED` | `tenantId` 已初始化,不能重复初始化 | +| 500 | `TASK_CREATE_FAILED` | 创建任务失败 | +| 500 | `MESSAGE_SEND_FAILED` | in-server instant 同步发送失败(§6.5);底层错误见 `error.details` | | 500 | `VAPID_CONFIG_ERROR` | VAPID 配置不完整 | -| 500 | `TENANT_MASTER_KEY_MISSING` | 租户主密钥缺失或配置异常 | -| 500 | `INTERNAL_SERVER_ERROR` | 未分类内部错误 | + +未被上述分类捕获的内部异常不包成统一信封,直接抛给平台适配器(由运行时返回 5xx)。 + +### 9.1 处理阶段错误码(嵌套,非顶层) + +消息处理过程(取任务、调 LLM、发推送、清理)产生的错误码不作为顶层 `error.code`,而是出现在两处: + +- **in-server instant 路径(§6.5)**:包在 `MESSAGE_SEND_FAILED` 的 `error.details` 里。 +- **cron `send-notifications` 路径**:HTTP 仍 `200`(除非 VAPID 缺失 → `500 VAPID_CONFIG_ERROR`),逐任务汇总进 `data.details.failedTasks[].reason`。 + +| code | 含义 | +|---|---| +| `TENANT_MASTER_KEY_MISSING` | 租户主密钥缺失或配置异常 | +| `TASK_NOT_FOUND` | 任务不存在或已处理 | +| `INTERNAL_ERROR` | 取任务时未分类内部错误(重试耗尽后) | +| `PROCESSING_ERROR` | 单条消息处理失败(重试耗尽后) | +| `POST_SEND_CLEANUP_FAILED` | 消息已发送,但任务清理失败 | ## 10. 对接流程(标准) diff --git a/standards/service-worker-specification.md b/standards/service-worker-specification.md index 3804f96..1194f99 100644 --- a/standards/service-worker-specification.md +++ b/standards/service-worker-specification.md @@ -5,8 +5,8 @@ ## 版本信息 -- **版本号**: v2.1.0 -- **最后更新**: 2026-05-25 +- **版本号**: v2.3.1 +- **最后更新**: 2026-06-28 - **状态**: Stable - **关联标准**: [主动消息API端点标准](./active-messaging-api.md) @@ -407,7 +407,9 @@ function buildNotificationOptions(data) { } ``` -**通知数据结构**(与 [主动消息API标准](./active-messaging-api.md#72-字段说明) 一致): +> 上面 `buildNotificationOptions` 是「不用包」的手写参考实现,默认值(`vibrate`、`actions`、`renotify: true`)由你自定。SDK 的 `createNotificationFromPayload`(`@rei-standard/amsg-sw`)默认更克制:不带 `vibrate` / `actions`,`renotify` / `requireInteraction` / `silent` 默认 `false`,`tag` 缺省回落到 `payload.messageId`,这些都可被 `payload.notification` 覆盖。 + +**通知数据结构**(与 [主动消息API标准](./active-messaging-api.md#63-推送-wire-shapeamsgpush-判别联合) 一致): ```json { @@ -1375,6 +1377,24 @@ self.addEventListener('notificationclick', (event) => { ## 15. 变更日志 +### v2.3.1 (2026-06-09) + +#### 改进优化 + +**1. `showNotification` 被拒不卡死 dedupe** +- 浏览器拒绝展示系统通知(权限被撤、quota / OS 限制等)时,`showNotification(...)` 的 reject 被兜底捕获并记到 `console.error`,dedupe 状态照常推进(`onNotificationSettled` 一定执行)。这样后续同 key 的 backup 重复包不会被当成「首投未决」吞掉,用户仍能看到补出的通知。 + +### v2.3.0 (2026-06-03) + +#### 改进优化 + +**1. IndexedDB 连接韧性** +- IndexedDB 连接被浏览器强制关闭(backing store 出错 / 存储压力 / 清数据)后能自愈:缓存连接挂 `onclose` 失效重开,事务命中「连接已关闭」时清缓存并重开重试一次。dedupe 库与 queue / multipart 库一并修复。避免死连接被无限复用导致去重失灵、push 落库受阻。 + +**2. 业务感知的 DELIVER ack(`businessError`)** +- DELIVER ack 新增可选字段 `businessError`(非破坏):`onBusinessPayload` reject 或抛错时,ack 仍是 `ok: true` 但带上 `businessError: `;成功时不出现。`ok` 的含义明确为「已收下并分发」,不代表「业务已落库」。需要严格区分两者的消费方读 `businessError`。 +- 该失败会持久化到 dedupe 记录:之后同 key 的重复包被去重后,ack 仍带首包的 `businessError`,而不是回一个看着干净的 `ok:true, duplicate:true`。去重不会让 `onBusinessPayload` 重跑——这只让信号诚实,要可重试需消费方自做幂等。webpush `push` 路径无 ack,业务失败仅内部 `console.error`。 + ### v2.2.0 (2026-05-31) #### 改进优化