From 877c7f0d955617ff147f558e3ff829a7f60b9fde Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Mon, 31 Aug 2026 20:26:46 +0800 Subject: [PATCH] feat(web): add standalone browser SDK package --- .github/workflows/ci.yml | 35 ++ .github/workflows/npm-publish.yml | 1 + .github/workflows/registry-canary.yml | 23 ++ .github/workflows/web-npm-publish.yml | 178 +++++++++ CONTRIBUTING.md | 16 +- README.md | 33 +- README.zh-CN.md | 23 +- docs/development.md | 17 +- docs/standalone-web-package-design.md | 64 ++++ docs/web-sdk.md | 121 +++--- docs/zh-CN/development.md | 12 +- docs/zh-CN/web-sdk.md | 104 ++--- package.json | 6 +- packages/web/README.md | 233 +++++++++++ packages/web/README.zh-CN.md | 199 ++++++++++ packages/web/THIRD_PARTY_NOTICES.txt | 317 +++++++++++++++ packages/web/index.d.mts | 25 ++ packages/web/index.mjs | 11 + packages/web/package.json | 47 +++ scripts/build-web-package.mjs | 73 ++++ scripts/check-package-contract.mjs | 10 +- scripts/check-web-package-contract.mjs | 281 ++++++++++++++ scripts/declaration-contract.mjs | 139 +++++++ scripts/test-sdk-consumers.mjs | 446 ++++++++++++++++------ scripts/test-web-package-consumers.mjs | 11 + scripts/test-web-package-registry.mjs | 330 ++++++++++++++++ scripts/web-package-layout.mjs | 80 ++++ scripts/web-package-registry.mjs | 509 +++++++++++++++++++++++++ 28 files changed, 3078 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/web-npm-publish.yml create mode 100644 docs/standalone-web-package-design.md create mode 100644 packages/web/README.md create mode 100644 packages/web/README.zh-CN.md create mode 100644 packages/web/THIRD_PARTY_NOTICES.txt create mode 100644 packages/web/index.d.mts create mode 100644 packages/web/index.mjs create mode 100644 packages/web/package.json create mode 100644 scripts/build-web-package.mjs create mode 100644 scripts/check-web-package-contract.mjs create mode 100644 scripts/declaration-contract.mjs create mode 100644 scripts/test-web-package-consumers.mjs create mode 100644 scripts/test-web-package-registry.mjs create mode 100644 scripts/web-package-layout.mjs create mode 100644 scripts/web-package-registry.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be1c5f5..79df55c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,11 @@ jobs: ios=true quality=true ;; + packages/web/**|scripts/build-web-package.mjs|scripts/check-web-package-contract.mjs|scripts/test-web-package*.mjs|scripts/web-package-*.mjs|.github/workflows/web-npm-publish.yml) + web=true + quality=true + site=true + ;; web/**|fixtures/**|scripts/build-web-wasm.sh|scripts/test-web*.mjs|scripts/web-*) android=true ios=true @@ -471,6 +476,35 @@ jobs: - name: Verify npm package contents run: npm pack --dry-run --ignore-scripts + web-package-test: + name: Standalone Web Package Consumers + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + package-manager-cache: false + + - name: Install package managers and dependencies + run: | + corepack enable + npm install --global npm@12.0.1 + yarn install --immutable + + - name: Test standalone package and publication guards + env: + CHROME_PATH: /usr/bin/google-chrome + run: | + yarn test:web:registry + yarn test:web:package + quality: name: Lint, TypeScript, and Jest needs: changes @@ -528,6 +562,7 @@ jobs: ios-build-test, ios-rn-compatibility, web-test, + web-package-test, site-test, ] if: always() diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a7ba243..2d8cb65 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -21,6 +21,7 @@ concurrency: jobs: publish-npm: name: Publish to npm with OIDC + if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest timeout-minutes: 30 env: diff --git a/.github/workflows/registry-canary.yml b/.github/workflows/registry-canary.yml index 2172a5a..2983c8a 100644 --- a/.github/workflows/registry-canary.yml +++ b/.github/workflows/registry-canary.yml @@ -11,6 +11,12 @@ on: default: react-native-bs-diff-patch@latest type: string + web_package_spec: + description: Standalone Web npm package spec to validate + required: false + default: bs-diff-patch-web@latest + type: string + permissions: contents: read @@ -41,3 +47,20 @@ jobs: env: PACKAGE_SPEC: ${{ inputs.package_spec || 'react-native-bs-diff-patch@latest' }} run: node scripts/test-registry-consumers.mjs ${{ matrix.consumer }} + + standalone-web: + name: Standalone Web registry consumer + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Test published standalone Web package + env: + PACKAGE_SPEC: ${{ inputs.web_package_spec || 'bs-diff-patch-web@latest' }} + CHROME_PATH: /usr/bin/google-chrome + run: yarn test:web:package diff --git a/.github/workflows/web-npm-publish.yml b/.github/workflows/web-npm-publish.yml new file mode 100644 index 0000000..b28fa9e --- /dev/null +++ b/.github/workflows/web-npm-publish.yml @@ -0,0 +1,178 @@ +name: Publish standalone Web npm package + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Published Web release tag to publish or verify (web-v0.5.0) + required: true + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: web-npm-publish-${{ inputs.release_tag || github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + publish-web: + name: Publish or verify Web package + if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'web-v') + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + CHROME_PATH: /usr/bin/google-chrome + steps: + - name: Validate release request + env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/main ]]; then + echo 'Manual publication must use the main workflow.' >&2 + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^web-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo 'A web-v version tag is required.' >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + jq -e --arg tag "$RELEASE_TAG" '.draft == false and .published_at != null and .tag_name == $tag' "$RUNNER_TEMP/release.json" > /dev/null + + - name: Checkout exact release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: refs/tags/${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js for trusted publishing + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org/ + package-manager-cache: false + + - name: Install package managers and dependencies + run: | + corepack enable + npm install --global npm@12.0.1 + yarn install --immutable + + - name: Verify release identity + id: release + run: | + tag_commit="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" + test "$(git rev-parse HEAD)" = "$tag_commit" + git merge-base --is-ancestor HEAD origin/main + package_version="$(node -p "require('./packages/web/package.json').version")" + test "$RELEASE_TAG" = "web-v$package_version" + if [[ "$package_version" == *-* ]]; then + prerelease="${package_version#*-}" + dist_tag="${prerelease%%.*}" + else + dist_tag=latest + fi + if [[ ! "$dist_tag" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo 'Invalid npm dist-tag derived from package version.' >&2 + exit 1 + fi + echo "version=$package_version" >> "$GITHUB_OUTPUT" + echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" + + - name: Run release quality gates + run: | + yarn prepare + yarn typecheck + yarn lint + yarn test --runInBand + yarn test:web + yarn test:web:browser + yarn test:web:metro + yarn test:toolkit + yarn test:node + yarn test:action + yarn test:package + yarn test:sdk + yarn test:web:registry + yarn test:web:package + git diff --exit-code HEAD + + - name: Pack immutable candidate + id: candidate + run: | + cd build/web-package + npm pack --json --pack-destination "$RUNNER_TEMP" > "$RUNNER_TEMP/web-pack.json" + filename="$(node -e 'const m=require(process.argv[1]); const e=Array.isArray(m)?m:Object.values(m); if(e.length!==1)process.exit(1); process.stdout.write(e[0].filename)' "$RUNNER_TEMP/web-pack.json")" + echo "tarball=$RUNNER_TEMP/$filename" >> "$GITHUB_OUTPUT" + + - name: Test exact candidate and check registry + id: registry + env: + PACKAGE_TARBALL: ${{ steps.candidate.outputs.tarball }} + run: | + yarn test:web:package + node scripts/web-package-registry.mjs status "$PACKAGE_TARBALL" > "$RUNNER_TEMP/web-registry-status.json" + echo "published=$(jq -r '.published' "$RUNNER_TEMP/web-registry-status.json")" >> "$GITHUB_OUTPUT" + + - name: Publish new version using OIDC + if: steps.registry.outputs.published == 'false' + env: + PACKAGE_TARBALL: ${{ steps.candidate.outputs.tarball }} + DIST_TAG: ${{ steps.release.outputs.dist_tag }} + run: npm publish "$PACKAGE_TARBALL" --provenance --access public --tag "$DIST_TAG" --registry=https://registry.npmjs.org/ + + - name: Verify registry bytes and provenance policy + env: + PACKAGE_TARBALL: ${{ steps.candidate.outputs.tarball }} + ALREADY_PUBLISHED: ${{ steps.registry.outputs.published }} + run: | + provenance=() + if [[ "$ALREADY_PUBLISHED" != true ]]; then + provenance+=(--require-provenance) + fi + verified=false + for attempt in $(seq 1 12); do + if node scripts/web-package-registry.mjs verify "$PACKAGE_TARBALL" "${provenance[@]}" > "$RUNNER_TEMP/web-registry-verification.json"; then + verified=true + break + fi + echo "Registry verification pending (attempt $attempt)." + sleep 5 + done + test "$verified" = true + cat "$RUNNER_TEMP/web-registry-verification.json" + if [[ "$ALREADY_PUBLISHED" == true ]]; then + echo 'Existing identical release verified; no upload or retroactive provenance claim.' >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Smoke test official registry package + env: + PACKAGE_SPEC: bs-diff-patch-web@${{ steps.release.outputs.version }} + run: yarn test:web:package + + - name: Verify npm registry signatures + env: + PACKAGE_VERSION: ${{ steps.release.outputs.version }} + run: | + mkdir "$RUNNER_TEMP/web-signatures" + cd "$RUNNER_TEMP/web-signatures" + npm init --yes + npm install --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org/ "bs-diff-patch-web@$PACKAGE_VERSION" + npm audit signatures --registry=https://registry.npmjs.org/ + + - name: Archive publication evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-package-publication + path: | + ${{ runner.temp }}/bs-diff-patch-web-*.tgz + ${{ runner.temp }}/web-pack.json + ${{ runner.temp }}/web-registry-*.json + if-no-files-found: ignore + retention-days: 30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 158b7a5..73e5ead 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,8 +75,8 @@ yarn test:web:metro yarn test:sdk ``` -`test:sdk` installs the prepared package tarball into an isolated consumer and -checks the public `/web` and `/toolkit` ESM entries, the production Vite +`test:sdk` installs the prepared React Native package tarball into an isolated +consumer and checks its `/web` and `/toolkit` ESM entries, the production Vite resource graph, and real byte round trips. It does not use a workspace link or the registry's older package. @@ -121,6 +121,13 @@ starts `.github/workflows/npm-publish.yml`, which publishes to npm through OIDC Trusted Publishing and verifies the package provenance. No long-lived npm token is stored in GitHub. +The flow in this section is for the existing `react-native-bs-diff-patch` +package. The standalone Web package `bs-diff-patch-web` has its own release and +tag namespace (`web-v0.5.0`) and does not replace, deprecate, or republish the +React Native package. Keep Node filesystem operations, the CLI, and related +release tooling on `react-native-bs-diff-patch`. See the [standalone Web package +design and first-publication procedure](./docs/standalone-web-package-design.md). + Maintainers should run the quality gates, then create the release: ```sh @@ -196,8 +203,11 @@ The `package.json` file contains various scripts for common tasks: - `yarn test:web`: verify the WebAssembly patch format and round trip. - `yarn test:web:browser`: exercise the public Web Worker API in Chrome. - `yarn test:web:metro`: verify Metro resolves the React Native Web entry. -- `yarn test:sdk`: install the prepared tarball and verify `/web` and `/toolkit` +- `yarn test:sdk`: install the prepared RN tarball and verify `/web` and `/toolkit` from an isolated Vite consumer. +- `yarn test:web:package`: build and check the independent Web tarball, then verify + its root and `/toolkit` from an isolated consumer without RN dependencies. +- `yarn test:web:registry`: verify the registry publication safety guards. - `node scripts/check-package-contract.mjs`: verify exports, declarations, packed assets, and the Node-free browser resource graph. - `yarn site:build`: render public Markdown and static site assets into `site-dist/`. diff --git a/README.md b/README.md index b2d5eb7..f32f305 100644 --- a/README.md +++ b/README.md @@ -88,20 +88,23 @@ patch application uses the bounded streaming core. Try the workflow in the brows ## Install +For React Native, Node.js, and CLI consumers: + ```sh npm install react-native-bs-diff-patch@^0.5.0 ``` -The explicit `/web` and `/toolkit` entries are part of 0.5.0. For pre-release -verification of a locally prepared package, the same entries can be tested from -its tarball instead: +For standalone browser and desktop WebView consumers: ```sh -npm install ./react-native-bs-diff-patch-0.5.0.tgz +npm install bs-diff-patch-web@^0.5.0 ``` -The registry's 0.4.x package predates these subpaths. See the [Web and desktop -WebView SDK guide](./docs/web-sdk.md) for the resource graph and consumer checks. +For pre-release verification of either locally prepared package, substitute its +tarball in a clean consumer. The standalone Web package uses +`bs-diff-patch-web-0.5.0.tgz`; the React Native package keeps its own tarball. +See the [Web and desktop WebView SDK guide](./docs/web-sdk.md) for the resource +graph, migration, and consumer checks. For iOS, install Pods and rebuild the native application: @@ -156,14 +159,15 @@ try { ## Web: first round trip -Standalone browser, Vite, and Tauri consumers should import the explicit ESM -entry `react-native-bs-diff-patch/web`; it exposes byte APIs and does not -require React Native. The root package keeps its conditional React Native and -browser resolution for existing applications. See the [Web and desktop WebView -SDK guide](./docs/web-sdk.md) for the published resource graph and CSP. +Standalone browser, Vite, and Tauri consumers should install +`bs-diff-patch-web` and import its ESM root; it exposes byte APIs without +React Native, Node.js, or a Node sidecar. Existing applications can keep using +`react-native-bs-diff-patch/web`, which remains supported. See the [Web and +desktop WebView SDK guide](./docs/web-sdk.md) for the resource graph, migration, +and CSP. ```ts -import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web'; +import { diffBytes, patchBytes } from 'bs-diff-patch-web'; const patchBytesValue = await diffBytes(oldFile, newFile, { signal: abortController.signal, @@ -254,8 +258,9 @@ TypeScript resolution from the real npm package shape. ## Documentation - [Web and desktop WebView SDK](./docs/web-sdk.md) — use the explicit ESM - `/web` and `/toolkit` entries from Vite or Tauri without React Native or a - Node sidecar. + `bs-diff-patch-web` and its `/toolkit` entry from Vite or Tauri without React + Native or a Node sidecar. Existing `react-native-bs-diff-patch/web` and + `/toolkit` consumers remain supported. - [Getting started](./docs/getting-started.md) - [API reference](./docs/api-reference.md) - [Production recipes](./docs/recipes.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 5803ab7..3a581ad 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -83,19 +83,21 @@ npx react-native-bs-diff-patch bundle \ ## 安装 +React Native、Node.js 和 CLI 消费者: + ```sh npm install react-native-bs-diff-patch@^0.5.0 ``` -明确的 `/web` 与 `/toolkit` 入口属于 0.5.0。发布前验证本地准备的包时,也可以改用其 -tarball: +独立浏览器和桌面 WebView 消费者: ```sh -npm install ./react-native-bs-diff-patch-0.5.0.tgz +npm install bs-diff-patch-web@^0.5.0 ``` -registry 的 0.4.x 包尚未包含这些子路径。资源图和消费者检查详见 -[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。 +发布前验证本地准备的包时,可以在干净消费者中将对应安装命令替换为 tarball。独立 Web +包使用 `bs-diff-patch-web-0.5.0.tgz`;React Native 包仍使用自己的 tarball。资源图、迁移 +和消费者检查详见[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。 iOS 还需要安装 Pods,并重新构建原生应用: @@ -150,13 +152,13 @@ try { ## Web:第一次往返 -独立浏览器、Vite 和 Tauri 消费者应导入明确的 -`react-native-bs-diff-patch/web` ESM 入口;它提供字节 API,不需要 React Native。 -根包继续为已有应用保留 React Native 与 browser 条件解析。发布资源图和 CSP 见 +独立浏览器、Vite 和 Tauri 消费者应安装 `bs-diff-patch-web` 并导入其 ESM 根入口;它提供 +不需要 React Native、Node.js 或 Node sidecar 的字节 API。已有应用可以继续使用 +`react-native-bs-diff-patch/web`,该入口仍受支持。资源图、迁移和 CSP 见 [Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。 ```ts -import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web'; +import { diffBytes, patchBytes } from 'bs-diff-patch-web'; const patchBytesValue = await diffBytes(oldFile, newFile, { signal: abortController.signal, @@ -242,7 +244,8 @@ npm 包消费测试还覆盖 browser、ESM、CommonJS、Metro 与 TypeScript 解 ## 完整文档 - [Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md) — 从 Vite 或 Tauri 使用明确的 - `/web` 与 `/toolkit` ESM 入口,无需 React Native 或 Node sidecar。 + `bs-diff-patch-web` 与其 `/toolkit` 入口,无需 React Native 或 Node sidecar;已有 + `react-native-bs-diff-patch/web` 与 `/toolkit` 消费者继续受支持。 - [快速开始](./docs/zh-CN/getting-started.md) - [API 参考](./docs/zh-CN/api-reference.md) - [生产实践](./docs/zh-CN/recipes.md) diff --git a/docs/development.md b/docs/development.md index aa48ce3..027766f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,9 +43,12 @@ yarn test:sdk TurboModule facade. - `test:package` installs the real tarball into a clean consumer and verifies browser, ESM, CommonJS, TypeScript, and optional-peer behavior. -- `test:sdk` installs the prepared tarball into an isolated Vite consumer and - verifies the explicit `/web` and `/toolkit` ESM entries, production resource - loading, and real byte round trips. +- `test:sdk` installs the prepared RN tarball into an isolated Vite consumer and + verifies `/web` and `/toolkit`, production resource loading, and real byte round trips. +- `test:web:package` builds and checks the separate Web tarball, then verifies its + root and `/toolkit` in a clean consumer without RN dependencies. +- `test:web:registry` checks publication guards for missing versions, mismatched + contents, network errors, signatures metadata, and provenance policy. ## Native robustness and compatibility @@ -188,6 +191,14 @@ The npm package's Trusted Publisher is already configured with these values: No npm-side change is required for a normal release, and the workflow does not use a long-lived npm token. +The checklist above is for the existing `react-native-bs-diff-patch` package. +The standalone `bs-diff-patch-web` package is released independently under the +`web-v0.5.0` tag namespace and does not replace, deprecate, or republish the +React Native package. Keep Node filesystem operations, the CLI, and their +release tooling on `react-native-bs-diff-patch`. The [standalone Web design](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/docs/standalone-web-package-design.md) +documents the local first release, subsequent Trusted Publishing setup, +`web-npm-publish.yml`, and byte-identical retry policy. + ## Recovering a failed npm publication For an existing tag and published GitHub Release (the examples below use diff --git a/docs/standalone-web-package-design.md b/docs/standalone-web-package-design.md new file mode 100644 index 0000000..e8fdb8d --- /dev/null +++ b/docs/standalone-web-package-design.md @@ -0,0 +1,64 @@ +# 独立 Web SDK 的设计与发布 + +## 目标与边界 + +新增 `bs-diff-patch-web`,让浏览器、Vite 和桌面 WebView 用户从包根获得字节 API,从 `/toolkit` 获得纯工具层。首版为 `0.5.0`,与已发布的 `react-native-bs-diff-patch@0.5.0` 的功能基线对应;后续两个包可独立升版。 + +该拆分减少安装体积,明确入口、类型和发布边界,不改变算法、补丁格式、运行速度或 WASM 内存需求。旧 RN 包无需重新发布,保留 RN、Web、Node、CLI 和 Action 的现有入口。此设计不包含下游文件授权、Rust 服务、保存流程或真实 Tauri WebView 验收。 + +## 源码与产物 + +| 内容 | 唯一实现 | 新包产物 | +| --------------- | -------------------------------------- | --------------------------------------------- | +| Web 字节 API | `web/index.mjs` | `web/index.mjs`,由根 facade 选择性导出 | +| Worker 生命周期 | `web/worker.browser.mjs` 等 | 原文件逐字节复制 | +| 浏览器 WASM | `web/bsdiffpatch.browser.mjs` | single-file Emscripten 模块,无 Node 文件系统 | +| Web 类型 | `web/index.d.mts` | 原文件复制,根 facade 仅公开 Web 类型 | +| 工具层 | `toolkit/index.mjs`、`index.d.ts` | 原文件逐字节复制,公开 `/toolkit` | +| Node/CLI | `node/`、`bin/`、`web/bsdiffpatch.mjs` | 不进入新包,原包保持不变 | + +根 facade 不实现算法,只显式导出可用 Web API。原包的 `diff/patch` 路径 API 在 Web 中返回 `EUNSUPPORTED`;它们以及 `NativeOperation*` 类型不出现在新包根公开接口。`startDiff/startPatch` 的字节任务别名继续可用。 + +`packages/web/package.json` 是 private 模板,不是新增 Yarn workspace。模板的 `prepack` 在任何生成目录操作之前拒绝直接打包。`yarn build:web:package` 根据白名单生成被 Git 忽略的 `build/web-package/`,去除 private、脚本和开发配置。实际发布只使用这个目录的 tarball;不能从仓库根发布新包。 + +生成包为 ESM,无 dependencies、peerDependencies、React/RN、原生源码、Node 入口或安装脚本。包内容合约验证精确文件集合、复制内容、公开入口、模块依赖图、运行时与声明的值导出双向一致。没有将旧 RN npm 包作为依赖,也没有复制一套独立维护的 WASM 或 toolkit 源码。 + +## 消费接口与兼容性 + +```ts +import { diffBytes, patchBytes, verifyPatch } from 'bs-diff-patch-web'; +import { inspectPatchHeader } from 'bs-diff-patch-web/toolkit'; + +const delta = await diffBytes(base, target, { + maxOutputBytes: 64 * 1024 * 1024, +}); +const header = inspectPatchHeader(delta); +const restored = await patchBytes(base, delta); +const result = await verifyPatch(base, delta, target); +``` + +`BinaryInput` 接受 Blob、ArrayBuffer 和视图;普通字节调用不转移调用方输入。显式资源预算、AbortSignal、任务取消、进度、Worker 生命周期和错误语义复用现有实现。默认预算不因拆包而收紧;2 GiB 是生成 WASM 的配置上限,不是可保证分配的内存,也不是进程总占用限制。CSP 需要允许同源 Worker 和 WASM;不使用 CDN 或一般 JavaScript `unsafe-eval` 兜底。 + +Web 生成/应用 ENDSLEY/BSDIFF43;BSDIFF40 只做头部识别并解释不支持。工具层结构校验不读文件、不联网、不验签,canonical payload 和哈希不证明可信来源。完整接入约束见 [Web SDK](./zh-CN/web-sdk.md)。 + +## 验证策略 + +共享消费者脚本保留 RN 默认模式,新增独立 Web profile。每次先在空目录正常安装候选 tarball,验证无 RN/React/旧包依赖,然后在 `skipLibCheck: false` 下分别用 NodeNext 与 Bundler 解析类型,验证原生路径 API 不可导入。生产 Vite 输出在真实 Chrome 中运行 Worker/WASM,阻止外网连接并检查 CSP、资源 URL、输入所有权、校验失败、取消、并发和资源上限。 + +独立的共装 fixture 验证新包与正式 RN 0.5.0 的双向补丁互通,避免旧包掩盖纯 Web fixture 的缺失依赖。另保留正式 registry 0.4.0 的固定 SRI 及 native C 交叉还原测试。原 RN/Node/CLI/Metro、工具层、原生保护、模糊测试和站点检查继续执行。 + +CI 增加独立 Web 包门禁,并纳入 `Complete CI`。仅包模板、构建/发布工具变更运行 Web/质量/文档门禁;共享 C/Web 源变更继续触发原生兼容性门禁。每周 registry canary 分别保留旧包 Vite/Expo 检查和新包 Web 检查。 + +## 首版发布与 Trusted Publishing + +首版按维护者要求先本地发布真实完整包,然后配置 Trusted Publishing,不发布占位版本、不关闭 2FA、不向 GitHub 保存长期 npm token。 + +1. 完成评审、全部适用门禁、正常 PR 合并。基于最新 main 的干净提交重新生成、验证并正常 `npm pack`,保存候选 tarball 与 SHA-512 SRI/SHA-256。 +2. 用官方 registry 查询确认版本不存在;网络错误、权限错误或内容不符都不能视为“未发布”。用 `PACKAGE_TARBALL` 验证冻结的确切候选,执行 `npm publish --access public --registry=https://registry.npmjs.org/` 并完成 npm 身份验证。 +3. 从官方 registry 下载并逐字节核验,同步验证签名、纯消费者和补丁互通。首版本地上传不宣称 GitHub Actions provenance。 +4. 包存在后,使用 npm 包设置或 `npm trust github bs-diff-patch-web --repo JimmyDaddy/react-native-bs-diff-patch --file web-npm-publish.yml --allow-publish --yes` 配置专属工作流信任,读取设置确认生效。 +5. 从发布提交创建独立 `web-v0.5.0` tag 和 GitHub Release。`web-npm-publish.yml` 验证版本、精确 tag 提交及其在 main 中的可达性。首次运行发现同版本已存在时,只在候选 SRI 完全一致的情况下跳过上传,继续 registry 验证;不会补造 provenance。 + +后续 Web 版本使用 `web-v`,由专属工作流通过 OIDC 发布并生成 provenance。旧 `npm-publish.yml` 仅处理 `v`,不会被 Web release 触发上传旧包。手动重试必须从 main 工作流指定已公开的 Web release;不可移动旧 tag 或覆盖已发布版本。 + +发布保护脚本对 registry 404、其他 HTTP/网络失败、元数据不符、候选 SRI 不符、下载字节不符及 provenance 策略分别验证。已存在版本若与候选不同即失败,不通过改版本标签或忽略检查掩盖差异。 diff --git a/docs/web-sdk.md b/docs/web-sdk.md index 3904a38..63bfd68 100644 --- a/docs/web-sdk.md +++ b/docs/web-sdk.md @@ -2,33 +2,40 @@ This guide is for a browser, Tauri 2 WebView, or another TypeScript application that needs the binary patch engine without installing React Native -or starting a Node sidecar. The SDK operates on bytes. A desktop application -still owns file dialogs, permissions, file reads and writes, temporary paths, -job policy, and final replacement in its Rust or platform layer. +or starting a Node sidecar. New Web-only consumers should use the standalone +`bs-diff-patch-web` package. Existing `react-native-bs-diff-patch` consumers can +keep their root, `/web`, and `/toolkit` imports. The SDK operates on bytes. A +desktop application still owns file dialogs, permissions, file reads and writes, +temporary paths, job policy, and final replacement in its Rust or platform +layer. ## Use the public entries -The package keeps the React Native root entry for existing applications and -adds explicit ESM entries for browser consumers: - -| Import | Module format | Use | -| --- | --- | --- | -| `react-native-bs-diff-patch/web` | ESM | Browser and WebView byte APIs, Worker jobs, metadata inspection and verification | -| `react-native-bs-diff-patch/toolkit` | ESM | Platform-neutral manifest, bundle and patch-header helpers | -| `react-native-bs-diff-patch` | Conditional | Existing React Native API; browser bundlers may select its browser condition | -| `react-native-bs-diff-patch/node` | ESM | Node filesystem operations and release tooling | - -`/web` and `/toolkit` intentionally do not expose a separate CommonJS -`require` entry. Use a bundler or a native ESM import. The root package keeps -its existing CommonJS build for consumers that already depend on it; that -compatibility path does not make path-based native APIs available in a WebView. - -The browser resource graph is part of the published package. The `/web` -entry loads the browser WASM module from `web/bsdiffpatch.browser.mjs` through -the module Worker graph. The Node entry keeps using `web/bsdiffpatch.mjs`, -which includes the Node filesystem support needed by `/node` and the CLI. Do -not alias one artifact to the other, import repository source paths, or add a -CDN fallback. Vite and other standard ESM bundlers should retain the +The standalone package is the recommended public surface for Web and WebView +consumers: + +| Import | Module format | Use | +| ------------------------------------ | ------------- | -------------------------------------------------------------------------------- | +| `bs-diff-patch-web` | ESM | Browser and WebView byte APIs, Worker jobs, metadata inspection and verification | +| `bs-diff-patch-web/toolkit` | ESM | Platform-neutral manifest, bundle and patch-header helpers | +| `react-native-bs-diff-patch/web` | ESM | Existing package's compatible Web surface | +| `react-native-bs-diff-patch/toolkit` | ESM | Existing package's compatible toolkit surface | +| `react-native-bs-diff-patch` | Conditional | Existing React Native API; browser bundlers may select its browser condition | +| `react-native-bs-diff-patch/node` | ESM | Node filesystem operations and release tooling | + +The standalone root and `/toolkit` entries are ESM-only and intentionally do +not expose a CommonJS `require` entry. The standalone package has no runtime +dependencies or peer dependencies and contains no React Native, Node, or native +source requirement. Use a bundler or a native ESM import. The existing package +keeps its root CommonJS build and compatibility entries for consumers that +already depend on it; those entries are not deprecated. + +The standalone package owns its browser Worker/WASM resource graph inside its +package artifact. The existing `/web` entry continues to load +`web/bsdiffpatch.browser.mjs`, while the existing Node entry keeps using +`web/bsdiffpatch.mjs` for `/node` and the CLI. Do not alias artifacts, import +repository source paths, or add a CDN fallback. Vite and other standard ESM +bundlers should retain the package Worker's `new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })` relationship. @@ -37,20 +44,21 @@ relationship. Install the package in the application that owns the WebView: ```sh -# Main install path for the 0.5.0 Web SDK: -npm install react-native-bs-diff-patch@^0.5.0 +# Recommended standalone package for Web and WebView consumers: +npm install bs-diff-patch-web@^0.5.0 ``` -For pre-release verification of a locally prepared package, substitute its -tarball: +For pre-release verification of a locally prepared standalone package, +substitute its tarball: ```sh -npm install ./react-native-bs-diff-patch-0.5.0.tgz +npm install ./bs-diff-patch-web-0.5.0.tgz ``` -The registry's 0.4.x package predates the `/web` and `/toolkit` subpaths. Do not -use an unversioned registry install as a pre-release verification of those -entries. +React Native, Node, and CLI consumers should continue to install +`react-native-bs-diff-patch@^0.5.0`. Its existing `/web` and `/toolkit` entries +remain available for compatibility. Do not use an unversioned registry install +as a pre-release verification of either package. The following code imports only the public Web entry and performs a real byte-to-byte round trip. It does not read a path and does not require React, @@ -62,7 +70,7 @@ import { inspectPatch, patchBytes, verifyPatch, -} from 'react-native-bs-diff-patch/web'; +} from 'bs-diff-patch-web'; const encoder = new TextEncoder(); const baseline = encoder.encode('release=1\nfeature=native\n'); @@ -120,7 +128,7 @@ Use a binary job when a UI needs progress, an explicit Cancel action, or a separate operation lifecycle: ```ts -import { startPatchBytes } from 'react-native-bs-diff-patch/web'; +import { startPatchBytes } from 'bs-diff-patch-web'; const job = startPatchBytes(oldFile, patchFile, { maxInputBytes: 64 * 1024 * 1024, @@ -194,19 +202,19 @@ ceiling when the toolchain or generated WASM build changes. Errors are ordinary `Error` values with a best-effort string `code`. Branch on the code, not diagnostic message text: -| Code | Meaning | -| --- | --- | -| `EINVAL` | Malformed or unsupported input type, or invalid option (native empty or duplicate paths are also invalid; zero-byte binary inputs are valid) | -| `EUNSUPPORTED` | Web Worker or the selected platform API is unavailable | -| `EABORTED` | A Web signal or job was cancelled | -| `ERESOURCE` | An input/output bound or detectable runtime allocation limit was exceeded | -| `EPATCH` | The patch header or patch payload is malformed or unsupported | -| `EWEBASSEMBLY` | Worker startup, resource loading, or an unclassified WASM failure | +| Code | Meaning | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `EINVAL` | Malformed or unsupported input type, or invalid option (native empty or duplicate paths are also invalid; zero-byte binary inputs are valid) | +| `EUNSUPPORTED` | Web Worker or the selected platform API is unavailable | +| `EABORTED` | A Web signal or job was cancelled | +| `ERESOURCE` | An input/output bound or detectable runtime allocation limit was exceeded | +| `EPATCH` | The patch header or patch payload is malformed or unsupported | +| `EWEBASSEMBLY` | Worker startup, resource loading, or an unclassified WASM failure | `inspectPatch()` is a cheap header inspection. It reads at most the 24-byte header from a binary input and does not apply or authenticate the patch. -`inspectPatchHeader()` from `/toolkit` has the same header-only purpose for a -caller's `Uint8Array`. `valid: true` means that the magic and declared +`inspectPatchHeader()` from `bs-diff-patch-web/toolkit` has the same header-only +purpose for a caller's `Uint8Array`. `valid: true` means that the magic and declared target-size header fields are structurally acceptable; it does not prove that compressed payload blocks are intact, that the baseline is correct, or that a signature is valid. Use `verifyPatch()` and a trusted digest/signature policy @@ -237,7 +245,7 @@ import { createPatchManifest, selectPatch, signingPayload, -} from 'react-native-bs-diff-patch/toolkit'; +} from 'bs-diff-patch-web/toolkit'; const manifest = createPatchManifest({ baseline: { bytes: 1000, sha256: baselineSha256 }, @@ -281,16 +289,18 @@ verify its digest, and then run `patchBytes()` and `verifyPatch()` as needed. ## Vite and Tauri packaging checklist Use the package name in application source and let the bundler follow its -exports. A production build should contain the `/web` entry's module Worker -and browser WASM resource graph. With the single-file WASM build, the binary -payload is embedded in the generated browser module; consumers do not need to -copy an independent `.wasm` file or install Emscripten. +exports. A production build should contain the standalone root entry's module +Worker and browser WASM resource graph. With the single-file WASM build, the +binary payload is embedded in the generated browser module; consumers do not +need to copy an independent `.wasm` file or install Emscripten. Before shipping, inspect the production bundle and run it from the built assets, including with network access disabled. Confirm that: -- `react-native-bs-diff-patch/web` and `/toolkit` resolve from the installed - tarball, with no workspace link, source alias, or private deep import; +- `bs-diff-patch-web` and `bs-diff-patch-web/toolkit` resolve from the installed + standalone tarball, with no workspace link, source alias, or private deep import; +- existing `react-native-bs-diff-patch/web` and `/toolkit` compatibility imports + remain available when an application intentionally uses the RN package; - the Worker URL resolves to a packaged same-origin asset; - browser WASM loads from the package resource graph, not a CDN; - the production app can generate, apply and verify a patch after the network @@ -321,13 +331,16 @@ yarn test:web:browser yarn test:web:metro yarn test:toolkit yarn test:sdk +yarn test:web:package +yarn test:web:registry yarn typecheck yarn site:build yarn site:test ``` -`yarn test:sdk` installs a prepared package tarball into an isolated consumer -and checks the public `/web` and `/toolkit` ESM entries, production Vite -resource loading and byte round trips. Registry smoke checks are a separate +`yarn test:sdk` preserves the RN package `/web` and `/toolkit` consumer checks. +`yarn test:web:package` builds and checks the standalone package tarball, then +installs it into an isolated consumer and checks its root and `/toolkit` ESM +entries, production Vite resource loading and byte round trips. Registry smoke checks are a separate post-release check. These checks do not constitute a Tauri device acceptance test or a claim that registry smoke has passed. diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index cdf9db3..b513a3a 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -42,8 +42,10 @@ yarn test:sdk - `test:web:metro` 证明 Metro 选择 `.web` 入口,而不是原生 TurboModule facade。 - `test:package` 将真实 tarball 安装到干净消费者,验证 browser、ESM、CommonJS、 TypeScript 与可选 peer 行为。 -- `test:sdk` 将准备好的 tarball 安装到隔离 Vite 消费者,验证明确的 `/web` 与 - `/toolkit` ESM 入口、生产资源加载和真实字节往返。 +- `test:sdk` 将准备好的 RN tarball 安装到隔离 Vite 消费者,验证 `/web` 与 `/toolkit`、 + 生产资源加载和真实字节往返。 +- `test:web:package` 构建并检查独立 Web tarball,在无 RN 依赖的干净消费者中验证包根与 `/toolkit`。 +- `test:web:registry` 验证版本不存在、内容不符、网络失败与 provenance 策略等发布保护。 ## 原生健壮性与兼容性 @@ -172,6 +174,12 @@ npm 包的 Trusted Publisher 已按以下值配置完成: 正常发布无需再修改 npm 侧配置,工作流也不使用长期 npm token。 +上面的清单针对已有的 `react-native-bs-diff-patch` 包。独立的 `bs-diff-patch-web` 使用 +`web-v0.5.0` tag 命名空间独立发布,不替代、不弃用也不重新发布 React Native 包。Node +文件系统操作、CLI 及其发布工具继续属于 `react-native-bs-diff-patch`。 +首版本地发布、随后配置 Trusted Publishing、独立 `web-npm-publish.yml` 和同内容重试规则 +见[独立 Web 包设计](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/docs/standalone-web-package-design.md)。 + ## npm 发布失败后的恢复 对于已有 tag 和已发布的 GitHub Release(以下示例使用 `v0.5.0`),恢复流程会复用已有 diff --git a/docs/zh-CN/web-sdk.md b/docs/zh-CN/web-sdk.md index 7725578..a65c700 100644 --- a/docs/zh-CN/web-sdk.md +++ b/docs/zh-CN/web-sdk.md @@ -1,30 +1,33 @@ # Web 与桌面 WebView SDK 本指南面向浏览器、Tauri 2 WebView 或其他 TypeScript 应用:在不安装 -React Native、也不启动 Node sidecar 的情况下使用二进制补丁引擎。SDK 处理的是 -字节;桌面应用仍负责文件选择、权限、读写文件、临时路径、任务策略,以及由 Rust -或平台层执行最终替换。 +React Native、也不启动 Node sidecar 的情况下使用二进制补丁引擎。新的 Web-only 消费者 +应使用独立的 `bs-diff-patch-web` 包。已有 `react-native-bs-diff-patch` 消费者可以继续 +使用根入口、`/web` 和 `/toolkit`。SDK 处理的是字节;桌面应用仍负责文件选择、权限、 +读写文件、临时路径、任务策略,以及由 Rust 或平台层执行最终替换。 ## 使用公开入口 -包保留现有 React Native 根入口,并为浏览器消费者提供明确的 ESM 入口: - -| 导入路径 | 模块格式 | 用途 | -| --- | --- | --- | -| `react-native-bs-diff-patch/web` | ESM | 浏览器与 WebView 字节 API、Worker job、元数据检查与验证 | -| `react-native-bs-diff-patch/toolkit` | ESM | 与平台无关的 manifest、bundle 与补丁头工具 | -| `react-native-bs-diff-patch` | 条件入口 | 既有 React Native API;浏览器打包器可以选择 browser 条件 | -| `react-native-bs-diff-patch/node` | ESM | Node 文件系统操作和发布工具 | - -`/web` 与 `/toolkit` 有意不提供单独的 CommonJS `require` 入口。请使用打包器或原生 -ESM 导入。根包继续保留已有 CommonJS 构建,供依赖该路径的消费者使用;这条兼容路径 -不会让 WebView 获得基于路径的原生 API。 - -浏览器资源图属于发布包的一部分。`/web` 入口通过模块 Worker 图加载 -`web/bsdiffpatch.browser.mjs` 中的浏览器 WASM 模块。Node 入口继续使用 -`web/bsdiffpatch.mjs`,为 `/node` 与 CLI 提供所需的 Node 文件系统支持。不要把两份 -产物互相 alias,不要导入仓库源码路径,也不要添加 CDN fallback。Vite 和其他标准 ESM -打包器应保留 +独立包是 Web 与 WebView 消费者推荐使用的公开界面: + +| 导入路径 | 模块格式 | 用途 | +| ------------------------------------ | -------- | -------------------------------------------------------- | +| `bs-diff-patch-web` | ESM | 浏览器与 WebView 字节 API、Worker job、元数据检查与验证 | +| `bs-diff-patch-web/toolkit` | ESM | 与平台无关的 manifest、bundle 与补丁头工具 | +| `react-native-bs-diff-patch/web` | ESM | 已有包的兼容 Web 界面 | +| `react-native-bs-diff-patch/toolkit` | ESM | 已有包的兼容 toolkit 界面 | +| `react-native-bs-diff-patch` | 条件入口 | 既有 React Native API;浏览器打包器可以选择 browser 条件 | +| `react-native-bs-diff-patch/node` | ESM | Node 文件系统操作和发布工具 | + +独立包的根入口与 `/toolkit` 只提供 ESM,有意不提供 CommonJS `require` 入口。独立包没有 +runtime dependencies 或 peerDependencies,也不要求 React Native、Node 或原生源码。请使用 +打包器或原生 ESM 导入。已有包继续保留根入口的 CommonJS 构建和兼容入口;这些入口不会 +弃用。 + +独立包在自身包 artifact 内管理浏览器 Worker/WASM 资源图。已有 `/web` 入口继续加载 +`web/bsdiffpatch.browser.mjs`,已有 Node 入口继续使用 `web/bsdiffpatch.mjs`,为 `/node` +与 CLI 提供支持。不要互相 alias 产物,不要导入仓库源码路径,也不要添加 CDN fallback。 +Vite 和其他标准 ESM 打包器应保留包内 Worker 的 `new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })` 关系。 ## 最小 Vite 或 Tauri 往返 @@ -32,18 +35,19 @@ ESM 导入。根包继续保留已有 CommonJS 构建,供依赖该路径的消 在拥有 WebView 的应用中安装包: ```sh -# 0.5.0 Web SDK 的主安装路径: -npm install react-native-bs-diff-patch@^0.5.0 +# Web 与 WebView 消费者推荐的独立包: +npm install bs-diff-patch-web@^0.5.0 ``` -发布前验证本地准备的包时,可以将其替换为 tarball: +发布前验证本地准备的独立包时,可以将其替换为 tarball: ```sh -npm install ./react-native-bs-diff-patch-0.5.0.tgz +npm install ./bs-diff-patch-web-0.5.0.tgz ``` -registry 中的 0.4.x 包尚未包含 `/web` 和 `/toolkit` 子路径。发布前验证这些入口时,不要 -使用未带版本的 registry 安装命令作为验证依据。 +React Native、Node 和 CLI 消费者应继续安装 `react-native-bs-diff-patch@^0.5.0`;其已有 +`/web` 和 `/toolkit` 入口继续用于兼容。发布前验证任一包时,不要使用未带版本的 registry +安装命令作为验证依据。 下面的代码只导入公开 Web 入口,执行真实的逐字节往返。它不读取路径,也不需要 React、React Native、Node 或服务器接口: @@ -54,7 +58,7 @@ import { inspectPatch, patchBytes, verifyPatch, -} from 'react-native-bs-diff-patch/web'; +} from 'bs-diff-patch-web'; const encoder = new TextEncoder(); const baseline = encoder.encode('release=1\nfeature=native\n'); @@ -107,7 +111,7 @@ Worker 内通过只读 WORKERFS 挂载供 C 核心读取,开始操作前不会 需要界面进度、明确的取消操作或独立任务生命周期时,使用二进制 job: ```ts -import { startPatchBytes } from 'react-native-bs-diff-patch/web'; +import { startPatchBytes } from 'bs-diff-patch-web'; const job = startPatchBytes(oldFile, patchFile, { maxInputBytes: 64 * 1024 * 1024, @@ -166,18 +170,18 @@ Worker 操作结束时,库会删除由操作拥有的 MEMFS 文件和监听器 错误是带有尽力分类字符串 `code` 的普通 `Error`。需要分支时使用 code,不要依赖错误 消息文本: -| Code | 含义 | -| --- | --- | -| `EINVAL` | 类型格式错误、不支持的输入类型或非法选项(原生空路径或重复路径也无效;零字节二进制输入有效) | -| `EUNSUPPORTED` | Web Worker 或选择的平台 API 不可用 | -| `EABORTED` | Web signal 或 job 被取消 | -| `ERESOURCE` | 超过输入/输出边界,或可识别的运行时分配限制 | -| `EPATCH` | 补丁头或补丁 payload 损坏或不支持 | -| `EWEBASSEMBLY` | Worker 启动、资源加载或未分类的 WASM 失败 | +| Code | 含义 | +| -------------- | -------------------------------------------------------------------------------------------- | +| `EINVAL` | 类型格式错误、不支持的输入类型或非法选项(原生空路径或重复路径也无效;零字节二进制输入有效) | +| `EUNSUPPORTED` | Web Worker 或选择的平台 API 不可用 | +| `EABORTED` | Web signal 或 job 被取消 | +| `ERESOURCE` | 超过输入/输出边界,或可识别的运行时分配限制 | +| `EPATCH` | 补丁头或补丁 payload 损坏或不支持 | +| `EWEBASSEMBLY` | Worker 启动、资源加载或未分类的 WASM 失败 | `inspectPatch()` 是低成本的头部检查。它从二进制输入最多读取 24 字节头,不应用也不 -认证补丁。`/toolkit` 的 `inspectPatchHeader()` 对调用方提供的 `Uint8Array` 具有相同 -的只检查头部目的。`valid: true` 只表示 magic 和声明的目标大小头字段在结构上可接受; +认证补丁。`bs-diff-patch-web/toolkit` 的 `inspectPatchHeader()` 对调用方提供的 +`Uint8Array` 具有相同的只检查头部目的。`valid: true` 只表示 magic 和声明的目标大小头字段在结构上可接受; 不表示压缩 payload 完整、不表示基线正确,也不表示签名有效。替换应用数据前应结合 `verifyPatch()` 与可信摘要/签名策略。 @@ -202,7 +206,7 @@ import { createPatchManifest, selectPatch, signingPayload, -} from 'react-native-bs-diff-patch/toolkit'; +} from 'bs-diff-patch-web/toolkit'; const manifest = createPatchManifest({ baseline: { bytes: 1000, sha256: baselineSha256 }, @@ -236,14 +240,15 @@ artifact 的摘要,然后按需要运行 `patchBytes()` 和 `verifyPatch()`。 ## Vite 与 Tauri 打包检查清单 -在应用源码中使用包名,让打包器依据 exports 解析。生产构建必须包含 `/web` 入口的 -模块 Worker 与浏览器 WASM 资源图。当前采用单文件 WASM 构建时,二进制 payload 嵌入 -生成的浏览器模块;消费者不需要复制独立 `.wasm` 文件,也不需要安装 Emscripten。 +在应用源码中使用包名,让打包器依据 exports 解析。生产构建必须包含独立包根入口的模块 +Worker 与浏览器 WASM 资源图。当前采用单文件 WASM 构建时,二进制 payload 嵌入生成的 +浏览器模块;消费者不需要复制独立 `.wasm` 文件,也不需要安装 Emscripten。 发布前应检查生产 bundle,并在断网条件下从构建产物运行,至少确认: -- `react-native-bs-diff-patch/web` 与 `/toolkit` 从安装的 tarball 解析,不使用 workspace - link、源码 alias 或私有深路径; +- `bs-diff-patch-web` 与 `bs-diff-patch-web/toolkit` 从安装的独立包 tarball 解析,不使用 + workspace link、源码 alias 或私有深路径; +- 应用有意使用 RN 包时,已有 `react-native-bs-diff-patch/web` 与 `/toolkit` 兼容导入仍可用; - Worker URL 解析到随包发布的同源资源; - 浏览器 WASM 从包资源图加载,而不是 CDN; - 断网后生产应用仍能生成、应用和验证补丁; @@ -271,11 +276,14 @@ yarn test:web:browser yarn test:web:metro yarn test:toolkit yarn test:sdk +yarn test:web:package +yarn test:web:registry yarn typecheck yarn site:build yarn site:test ``` -其中 `yarn test:sdk` 会把准备好的 tarball 安装到隔离消费者中,检查公开 `/web`、 -`/toolkit` ESM 入口、生产 Vite 资源加载和字节往返。Registry smoke 属于独立的发布后 -检查;这些检查不等于 Tauri 真机验收,也不宣称 registry smoke 已通过。 +其中 `yarn test:sdk` 保留 RN 包 `/web` 与 `/toolkit` 的消费者检查。 +`yarn test:web:package` 构建并检查独立包 tarball,在隔离消费者中验证包根、 +`/toolkit` ESM、生产 Vite 资源加载和字节往返。Registry smoke 属于独立的发布后检查; +这些检查不等于 Tauri 真机验收,也不宣称 registry smoke 已通过。 diff --git a/package.json b/package.json index c027d43..28ccbc6 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,11 @@ "prepare": "bob build && node scripts/prepare-package.mjs && node scripts/check-package-contract.mjs", "prepack": "node scripts/check-package-contract.mjs", "build:web": "bash scripts/build-web-wasm.sh", - "release": "release-it" + "release": "release-it", + "build:web:package": "node scripts/build-web-package.mjs", + "check:web:package": "node scripts/check-web-package-contract.mjs", + "test:web:package": "yarn build:web:package && yarn check:web:package && node scripts/test-web-package-consumers.mjs", + "test:web:registry": "node --test scripts/test-web-package-registry.mjs" }, "keywords": [ "react-native", diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000..6e1b6c7 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,233 @@ +# bs-diff-patch-web + +`bs-diff-patch-web` is the standalone ESM package for browser, Vite, and +desktop WebView applications that need local binary patch operations. It does +not require React Native, Node.js, a Node sidecar, or native source code. + +The package is built from the same checked-in C and bzip2 sources as +`react-native-bs-diff-patch`, but it is released independently. The Web package +uses the `web-v0.5.0` tag for its 0.5.0 release; the existing React Native +package keeps its separate `v0.5.0` release and remains supported. + +## Install + +```sh +npm install bs-diff-patch-web@^0.5.0 +``` + +For pre-release verification, install the tarball produced by the package +build in a clean consumer: + +```sh +npm install ./bs-diff-patch-web-0.5.0.tgz +``` + +The tarball command is a package verification path. It does not require a +workspace link, a source alias, or a private deep import. + +## Public entries + +| Import | Format | Purpose | +| --------------------------- | ------ | -------------------------------------------------------------------------------------------------------- | +| `bs-diff-patch-web` | ESM | Browser and WebView byte APIs, Worker jobs, progress, cancellation, limits, inspection, and verification | +| `bs-diff-patch-web/toolkit` | ESM | Platform-neutral manifest, bundle, candidate-selection, canonicalization, and header helpers | + +Both entries are ESM-only. This package intentionally does not provide a +CommonJS `require` entry, Node filesystem APIs, or a CLI. The existing +`react-native-bs-diff-patch` package remains the compatibility path for React +Native and for the `/node` entry and CLI. + +The package has no runtime `dependencies` and no `peerDependencies`. Its +generated browser modules and Worker resources are included by the package +build. Consumers should import the package name and let Vite or another ESM +bundler follow the package exports; do not import repository paths or load the +engine from a CDN. + +## Byte operations + +```ts +import { + diffBytes, + inspectPatch, + patchBytes, + verifyPatch, +} from 'bs-diff-patch-web'; + +const encoder = new TextEncoder(); +const baseline = encoder.encode('release=1\nfeature=native\n'); +const target = encoder.encode('release=2\nfeature=native,web\n'); + +const patch = await diffBytes(baseline, target, { + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, + onProgress: ({ phase, progress }) => console.log(phase, progress), +}); + +const metadata = await inspectPatch(patch); +if (!metadata.valid || metadata.format !== 'ENDSLEY/BSDIFF43') { + throw new Error('unsupported patch header'); +} + +const restored = await patchBytes(baseline, patch, { + maxOutputBytes: 32 * 1024 * 1024, +}); +const verification = await verifyPatch(baseline, patch, target); + +if (!verification.verified || restored.length !== target.length) { + throw new Error('restored bytes do not match the target'); +} +``` + +`diffBytes`, `patchBytes`, `inspectPatch`, and `verifyPatch` accept an +`ArrayBuffer`, any `ArrayBufferView` (including `DataView`), or a `Blob`/`File`. +Zero-byte binary inputs are valid. Native path APIs in the React Native +package separately reject empty path strings. Results are new `Uint8Array` +instances, and the Worker does not take ownership of caller buffers. Blob and +File inputs are mounted read-only through WORKERFS while the C core reads them; +the application still owns its object URLs and returned buffer references. + +Keep patch bytes binary when storing or sending them. Converting arbitrary +patch bytes to UTF-8 can corrupt the patch. + +## Jobs, cancellation, and limits + +Use a job when the UI needs progress or an explicit Cancel action: + +```ts +import { startPatch } from 'bs-diff-patch-web'; + +const job = startPatch(oldFile, patchFile, { + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 128 * 1024 * 1024, + onProgress: renderProgress, +}); + +const unsubscribe = job.onProgress(renderProgress); +try { + const restored = await job.result; + consume(restored); +} catch (error) { + if ((error as { code?: string }).code !== 'EABORTED') throw error; +} finally { + unsubscribe(); +} +``` + +`startDiff`, `startPatch`, `startDiffBytes`, and `startPatchBytes` use the same +binary job contract. A job with a signal or job wrapper uses a dedicated +Worker, so cancellation is isolated from other work. `result` resolves to a +new `Uint8Array`; a cancelled result rejects with `EABORTED`. `cancel()` itself +resolves only after `result` reaches a terminal state and Worker/listener +cleanup has completed. Calling `cancel()` repeatedly is safe, and cancelling +an already completed job does not change its settled result. + +Calls without a signal share a serialized Worker queue. The SDK does not set +an aggregate application memory or concurrency budget; applications should +limit concurrent large jobs and release their own Blob URLs and buffer +references. + +`maxInputBytes` applies independently to every supplied input. It is not a +combined input or total-process-memory limit. `maxOutputBytes` is checked +before a declared patch target is decompressed and allocated and again for the +produced result. Limits must be non-negative safe integers; invalid values +reject with `EINVAL`, while exceeded limits reject with `ERESOURCE`. + +The current browser build retains Emscripten's configured 2 GiB maximum linear +memory setting. This is a build setting, not a WebAssembly-standard limit or a +universal hard ceiling for every engine. A browser, WebView, or device may +fail earlier because of its own WebAssembly or tab memory budget; detectable +allocation and memory-access failures are classified as `ERESOURCE`. + +## Errors and patch format + +Errors are ordinary `Error` values with a best-effort string `code`. Branch on +the code instead of diagnostic message text: + +| Code | Meaning | +| -------------- | ------------------------------------------------------------------------- | +| `EINVAL` | Invalid input type or option; zero-byte binary inputs remain valid | +| `EUNSUPPORTED` | Worker or selected platform API is unavailable | +| `EABORTED` | A Web signal or job was cancelled | +| `ERESOURCE` | An input/output limit or detectable runtime allocation limit was exceeded | +| `EPATCH` | The patch header or payload is malformed or unsupported | +| `EWEBASSEMBLY` | Worker startup, resource loading, or another unclassified WASM failure | + +Runtime generation and application use `ENDSLEY/BSDIFF43`. Header inspection +recognizes `BSDIFF40` as `valid: false` with `issue: 'LEGACY_FORMAT'`; it does +not silently apply that format. Conversion and the Node CLI remain in +`react-native-bs-diff-patch`: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` + +`inspectPatch` and `inspectPatchHeader` are structural header checks only. +`valid: true` does not prove that compressed payload blocks, the baseline, a +digest, or a signature is correct. `verifyPatch()` and the application's +trusted digest/signature policy are required before replacing application +data. + +## Toolkit trust boundary + +```ts +import { + canonicalJson, + createPatchBundle, + createPatchManifest, + selectPatch, + signingPayload, +} from 'bs-diff-patch-web/toolkit'; +``` + +The toolkit is platform-neutral. It does not read files, fetch URLs, compute +hashes, access private keys, verify signatures, or prove that bytes match a +manifest. It validates and normalizes caller-supplied structure; unknown +fields are dropped from normalized values. + +`canonicalJson()` and `signingPayload()` produce deterministic signing inputs; +neither function performs a digital signature. Array cycles are rejected with +`EINVALID_MANIFEST`, and a literal `__proto__` object key is preserved during +canonicalization. `selectPatch()` validates selection options before finding a +baseline, returns the first exact digest match, and applies byte and ratio +budgets to that candidate. It does not search for the smallest patch. Invalid +budgets reject even when no baseline candidate matches. + +## Packaging and CSP + +Use the package name in application source and let the production bundler +follow its exports. The package build includes the browser Worker and WASM +resource graph; consumers do not need Emscripten or a separate `.wasm` file. +Run the production bundle with network access disabled to confirm that the +Worker and WASM load from same-origin package resources. + +The minimum CSP additions for a browser or WebView are: + +```text +script-src 'self' 'wasm-unsafe-eval'; +worker-src 'self'; +``` + +Merge these directives with the application's existing policy. Do not add +ordinary `unsafe-eval`, use a CDN fallback, or move file authorization, +persistence, temporary files, or final replacement into this package. Tauri +WebView acceptance remains a downstream application test. + +## Migration from the React Native package + +Existing `react-native-bs-diff-patch` consumers can keep using its root, +`/web`, and `/toolkit` entries. Those entries are not deprecated and do not +need to be republished because this package exists. For a Web-only application +that wants no React Native package in its dependency graph: + +1. install `bs-diff-patch-web@^0.5.0`; +2. change Web byte imports from `react-native-bs-diff-patch/web` to + `bs-diff-patch-web`; +3. change toolkit imports from `react-native-bs-diff-patch/toolkit` to + `bs-diff-patch-web/toolkit`; +4. keep Node filesystem operations, the CLI, and the BSDIFF40 converter on + `react-native-bs-diff-patch`. + +The byte ownership, Worker lifecycle, resource limits, errors, patch format, +and toolkit trust rules are the same across the two Web entry surfaces. This +package does not claim Tauri or downstream mobile acceptance; validate those +applications in their own environments. diff --git a/packages/web/README.zh-CN.md b/packages/web/README.zh-CN.md new file mode 100644 index 0000000..53e01fa --- /dev/null +++ b/packages/web/README.zh-CN.md @@ -0,0 +1,199 @@ +# bs-diff-patch-web + +`bs-diff-patch-web` 是面向浏览器、Vite 和桌面 WebView 的独立 ESM 包,用于在本地执行 +二进制补丁操作。它不需要 React Native、Node.js、Node sidecar 或原生源码。 + +该包与 `react-native-bs-diff-patch` 共用同一份已检入的 C 与 bzip2 源码,但独立发布。 +Web 包的 0.5.0 release 使用 `web-v0.5.0` tag;已有 React Native 包继续使用独立的 +`v0.5.0` release,并保持支持。 + +## 安装 + +```sh +npm install bs-diff-patch-web@^0.5.0 +``` + +发布前验证时,可在干净消费者中安装包构建生成的 tarball: + +```sh +npm install ./bs-diff-patch-web-0.5.0.tgz +``` + +tarball 命令用于验证包本身,不需要 workspace link、源码 alias 或私有深路径。 + +## 公开入口 + +| 导入路径 | 格式 | 用途 | +| --------------------------- | ---- | ------------------------------------------------------------------- | +| `bs-diff-patch-web` | ESM | 浏览器与 WebView 字节 API、Worker job、进度、取消、限制、检查与验证 | +| `bs-diff-patch-web/toolkit` | ESM | 与平台无关的 manifest、bundle、候选选择、规范化和补丁头工具 | + +两个入口都只提供 ESM。该包有意不提供 CommonJS `require` 入口、Node 文件系统 API 或 +CLI。已有的 `react-native-bs-diff-patch` 继续作为 React Native 兼容路径,以及 `/node` +入口和 CLI 的来源。 + +该包没有 runtime `dependencies`,也没有 `peerDependencies`。生成的浏览器模块和 Worker +资源由包构建流程纳入发布包。消费者应导入包名,让 Vite 或其他 ESM 打包器遵循 exports; +不要导入仓库路径,也不要从 CDN 加载引擎。 + +## 字节操作 + +```ts +import { + diffBytes, + inspectPatch, + patchBytes, + verifyPatch, +} from 'bs-diff-patch-web'; + +const encoder = new TextEncoder(); +const baseline = encoder.encode('release=1\nfeature=native\n'); +const target = encoder.encode('release=2\nfeature=native,web\n'); + +const patch = await diffBytes(baseline, target, { + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, + onProgress: ({ phase, progress }) => console.log(phase, progress), +}); + +const metadata = await inspectPatch(patch); +if (!metadata.valid || metadata.format !== 'ENDSLEY/BSDIFF43') { + throw new Error('unsupported patch header'); +} + +const restored = await patchBytes(baseline, patch, { + maxOutputBytes: 32 * 1024 * 1024, +}); +const verification = await verifyPatch(baseline, patch, target); + +if (!verification.verified || restored.length !== target.length) { + throw new Error('restored bytes do not match the target'); +} +``` + +`diffBytes`、`patchBytes`、`inspectPatch` 与 `verifyPatch` 接受 `ArrayBuffer`、任意 +`ArrayBufferView`(包括 `DataView`)或 `Blob`/`File`。零字节二进制输入有效;React Native +包中的原生路径 API 另行拒绝空路径字符串。结果是新的 `Uint8Array`,Worker 不会接管调用方 +缓冲区的所有权。`Blob` 与 `File` 会在 Worker 中通过 WORKERFS 只读挂载供 C 核心读取; +应用仍负责自己的 object URL 和返回缓冲区引用。 + +保存或传输补丁时应保持二进制形式。将任意补丁字节转换为 UTF-8 可能破坏补丁。 + +## Job、取消与限制 + +界面需要进度或明确的 Cancel 操作时使用 job: + +```ts +import { startPatch } from 'bs-diff-patch-web'; + +const job = startPatch(oldFile, patchFile, { + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 128 * 1024 * 1024, + onProgress: renderProgress, +}); + +const unsubscribe = job.onProgress(renderProgress); +try { + const restored = await job.result; + consume(restored); +} catch (error) { + if ((error as { code?: string }).code !== 'EABORTED') throw error; +} finally { + unsubscribe(); +} +``` + +`startDiff`、`startPatch`、`startDiffBytes` 与 `startPatchBytes` 使用相同的二进制 job +契约。带 signal 或 job 封装的调用使用专用 Worker,因此取消只影响当前任务。`result` +成功时返回新的 `Uint8Array`;取消后的 `result` 以 `EABORTED` 拒绝。`cancel()` 只有在 +`result` 到达终态且 Worker/监听器清理完成后才 resolve。重复调用 `cancel()` 是安全的; +已完成的 job 再次取消不会改变其已确定的结果。 + +不带 signal 的调用共享串行 Worker 队列。SDK 不设置应用级总内存或并发预算;应用应限制 +大任务并发,并释放自己的 Blob URL 和缓冲区引用。 + +`maxInputBytes` 分别作用于每个输入,不是输入总和或进程总内存上限。`maxOutputBytes` +会在解压和分配补丁声明的目标之前检查,并再次检查实际结果。限制必须是非负安全整数; +非法值以 `EINVAL` 拒绝,超过限制以 `ERESOURCE` 拒绝。 + +当前浏览器构建保留 Emscripten 配置的 2 GiB 最大线性内存设置。这是构建设置,不是 +WebAssembly 标准规定的限制,也不是所有引擎的统一硬上限。浏览器、WebView 或设备可能 +因为自身的 WebAssembly 或标签页内存预算更早失败;可识别的分配和内存访问失败归类为 +`ERESOURCE`。 + +## 错误与补丁格式 + +错误是带有尽力分类字符串 `code` 的普通 `Error`。需要分支时使用 code,不要依赖错误消息: + +| Code | 含义 | +| -------------- | -------------------------------------------- | +| `EINVAL` | 输入类型或选项无效;零字节二进制输入仍然有效 | +| `EUNSUPPORTED` | Worker 或所选平台 API 不可用 | +| `EABORTED` | Web signal 或 job 被取消 | +| `ERESOURCE` | 输入/输出限制或可识别的运行时分配限制被超过 | +| `EPATCH` | 补丁头或 payload 损坏或不支持 | +| `EWEBASSEMBLY` | Worker 启动、资源加载或其他未分类 WASM 失败 | + +运行时生成和应用使用 `ENDSLEY/BSDIFF43`。头部检查会将 `BSDIFF40` 识别为 +`valid: false`、`issue: 'LEGACY_FORMAT'`,不会静默应用该格式。转换器和 Node CLI 仍在 +`react-native-bs-diff-patch` 中: + +```sh +npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch +``` + +`inspectPatch` 和 `inspectPatchHeader` 只做结构化头部检查。`valid: true` 不表示压缩 +payload、基线、摘要或签名正确。替换应用数据前必须运行 `verifyPatch()` 并遵循应用自己的 +可信摘要/签名策略。 + +## Toolkit 信任边界 + +```ts +import { + canonicalJson, + createPatchBundle, + createPatchManifest, + selectPatch, + signingPayload, +} from 'bs-diff-patch-web/toolkit'; +``` + +Toolkit 与平台无关,不读取文件、不下载 URL、不计算哈希、不访问私钥、不验证签名,也不 +证明字节与 manifest 一致。它只校验和规范化调用方提供的结构;未知字段会从规范化结果中 +丢弃。 + +`canonicalJson()` 与 `signingPayload()` 生成确定性的签名输入;二者都不会执行数字签名。 +数组循环以 `EINVALID_MANIFEST` 拒绝,规范化时保留字面量 `__proto__` 对象键。 +`selectPatch()` 会先校验选择选项再查找基线,返回第一个 digest 精确匹配,并对该候选应用 +字节和比例预算;它不会搜索最小补丁。即使没有匹配基线,非法预算也会拒绝。 + +## 打包与 CSP + +在应用源码中使用包名,让生产打包器遵循 exports。包构建会包含浏览器 Worker 和 WASM +资源图;消费者不需要 Emscripten 或独立 `.wasm` 文件。应在断网条件下运行生产 bundle, +确认 Worker 与 WASM 从同源包资源加载。 + +浏览器或 WebView 所需的最小 CSP 增量为: + +```text +script-src 'self' 'wasm-unsafe-eval'; +worker-src 'self'; +``` + +请将这些指令合并到应用已有策略中。不要添加普通 `unsafe-eval`、使用 CDN fallback,或把 +文件授权、持久化、临时文件和最终替换移入本包。真实 Tauri WebView 验收仍由下游应用负责。 + +## 从 React Native 包迁移 + +已有 `react-native-bs-diff-patch` 消费者可以继续使用其根入口、`/web` 和 `/toolkit`。这些 +入口没有弃用,也不会因为本包存在而重新发布。若 Web-only 应用希望依赖图中不包含 React +Native 包: + +1. 安装 `bs-diff-patch-web@^0.5.0`; +2. 将 Web 字节导入从 `react-native-bs-diff-patch/web` 改为 `bs-diff-patch-web`; +3. 将 toolkit 导入从 `react-native-bs-diff-patch/toolkit` 改为 + `bs-diff-patch-web/toolkit`; +4. Node 文件系统操作、CLI 和 BSDIFF40 转换器继续使用 `react-native-bs-diff-patch`。 + +两个 Web 入口面的字节 ownership、Worker 生命周期、资源限制、错误、补丁格式和 toolkit +信任规则相同。本包不宣称 Tauri 或下游移动端验收通过;这些应用应在自己的环境中验证。 diff --git a/packages/web/THIRD_PARTY_NOTICES.txt b/packages/web/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..a18d208 --- /dev/null +++ b/packages/web/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,317 @@ +bs-diff-patch-web third-party notices +====================================== + +This package distributes browser JavaScript and WebAssembly generated from the +components identified below. The project-level MIT license is in LICENSE; +the following notices apply to the separately copyrighted components. + + +bsdiff / bspatch +---------------- + +The WebAssembly payload includes the implementations in cpp/bsdiff.c and +cpp/bspatch.c. Their source headers contain this BSD 2-Clause notice: + +Copyright 2003-2005 Colin Percival +Copyright 2012 Matthew Endsley +All rights reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +bzip2 / libbzip2 1.0.6 +----------------------- + +The WebAssembly payload includes bzip2/libbzip2 1.0.6 sources. The complete +upstream LICENSE is reproduced below. Source: +https://gitlab.com/bzip2/bzip2/-/blob/bzip2-1.0.6/LICENSE + +-------------------------------------------------------------------------- + +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2010 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Julian Seward, jseward@bzip.org +bzip2/libbzip2 version 1.0.6 of 6 September 2010 + +-------------------------------------------------------------------------- + + +Emscripten runtime +------------------ + +bsdiffpatch.browser.mjs and its embedded WebAssembly payload are generated +with Emscripten. This distribution uses the MIT option in the Emscripten +dual license. The local compiler used for this build reports version +6.0.3-git. The full Emscripten license also offers the University of +Illinois/NCSA option; the MIT text used for this distribution follows. +Upstream source: https://github.com/emscripten-core/emscripten/blob/main/LICENSE + +Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +musl libc +--------- + +Emscripten's C runtime includes musl libc portions. The following is the +upstream COPYRIGHT notice supplied with the Emscripten toolchain. Source: +https://github.com/emscripten-core/emscripten/blob/main/system/lib/libc/musl/COPYRIGHT + +musl as a whole is licensed under the following standard MIT license: + +---------------------------------------------------------------------- +Copyright © 2005-2020 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------------------------------------------------------------- + +Authors/contributors include: + +A. Wilcox +Ada Worcester +Alex Dowad +Alex Suykov +Alexander Monakov +Andre McCurdy +Andrew Kelley +Anthony G. Basile +Aric Belsito +Arvid Picciani +Bartosz Brachaczek +Benjamin Peterson +Bobby Bingham +Boris Brezillon +Brent Cook +Chris Spiegel +Clément Vasseur +Daniel Micay +Daniel Sabogal +Daurnimator +David Carlier +David Edelsohn +Denys Vlasenko +Dmitry Ivanov +Dmitry V. Levin +Drew DeVault +Emil Renner Berthing +Fangrui Song +Felix Fietkau +Felix Janda +Gianluca Anzolin +Hauke Mehrtens +He X +Hiltjo Posthuma +Isaac Dunham +Jaydeep Patil +Jens Gustedt +Jeremy Huntwork +Jo-Philipp Wich +Joakim Sindholt +John Spencer +Julien Ramseier +Justin Cormack +Kaarle Ritvanen +Khem Raj +Kylie McClain +Leah Neukirchen +Luca Barbato +Luka Perkov +Lynn Ochs +M Farkas-Dyck (Strake) +Mahesh Bodapati +Markus Wichmann +Masanori Ogino +Michael Clark +Michael Forney +Mikhail Kremnyov +Natanael Copa +Nicholas J. Kain +orc +Pascal Cuoq +Patrick Oppenlander +Petr Hosek +Petr Skocik +Pierre Carrier +Reini Urban +Rich Felker +Richard Pennington +Ryan Fairfax +Samuel Holland +Segev Finer +Shiz +sin +Solar Designer +Stefan Kristiansson +Stefan O'Rear +Szabolcs Nagy +Timo Teräs +Trutz Behn +Will Dietz +William Haddon +William Pitcock + +Portions of this software are derived from third-party works licensed +under terms compatible with the above MIT license: + +The TRE regular expression implementation (src/regex/reg* and +src/regex/tre*) is Copyright © 2001-2008 Ville Laurikari and licensed +under a 2-clause BSD license (license text in the source files). The +included version has been heavily modified by Rich Felker in 2012, in +the interests of size, simplicity, and namespace cleanliness. + +Much of the math library code (src/math/* and src/complex/*) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier or +Copyright © 2017-2018 Arm Limited +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. + +The ARM memcpy code (src/string/arm/memcpy.S) is Copyright © 2008 +The Android Open Source Project and is licensed under a two-clause BSD +license. It was taken from Bionic libc, used on Android. + +The AArch64 memcpy and memset code (src/string/aarch64/*) are +Copyright © 1999-2019, Arm Limited. + +The implementation of DES for crypt (src/crypt/crypt_des.c) is +Copyright © 1994 David Burren. It is licensed under a BSD license. + +The implementation of blowfish crypt (src/crypt/crypt_blowfish.c) was +originally written by Solar Designer and placed into the public +domain. The code also comes with a fallback permissive license for use +in jurisdictions that may not recognize the public domain. + +The smoothsort implementation (src/stdlib/qsort.c) is Copyright © 2011 +Lynn Ochs and is licensed under an MIT-style license. + +The x86_64 port was written by Nicholas J. Kain and is licensed under +the standard MIT terms. + +The mips and microblaze ports were originally written by Richard +Pennington for use in the ellcc project. The original code was adapted +by Rich Felker for build system and code conventions during upstream +integration. It is licensed under the standard MIT terms. + +The mips64 port was contributed by Imagination Technologies and is +licensed under the standard MIT terms. + +The powerpc port was also originally written by Richard Pennington, +and later supplemented and integrated by John Spencer. It is licensed +under the standard MIT terms. + +All other files which have no copyright comments are original works +produced specifically for use as part of this library, written either +by Rich Felker, the main author of the library, or by one or more +contibutors listed above. Details on authorship of individual files +can be found in the git version control history of the project. The +omission of copyright and license comments in each file is in the +interest of source tree size. + +In addition, permission is hereby granted for all public header files +(include/* and arch/*/bits/*) and crt files intended to be linked into +applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit +the copyright notice and permission notice otherwise required by the +license, and to use these files without any requirement of +attribution. These files include substantial contributions from: + +Bobby Bingham +John Spencer +Nicholas J. Kain +Rich Felker +Richard Pennington +Stefan Kristiansson +Szabolcs Nagy + +all of whom have explicitly granted such permission. + +This file previously contained text expressing a belief that most of +the files covered by the above exception were sufficiently trivial not +to be subject to copyright, resulting in confusion over whether it +negated the permissions granted in the license. In the spirit of +permissive licensing, and of not having licensing issues being an +obstacle to adoption, that text has been removed. diff --git a/packages/web/index.d.mts b/packages/web/index.d.mts new file mode 100644 index 0000000..c07701b --- /dev/null +++ b/packages/web/index.d.mts @@ -0,0 +1,25 @@ +export { + classifyPatchError, + diffBytes, + inspectPatch, + patchBytes, + startDiff, + startDiffBytes, + startPatch, + startPatchBytes, + verifyPatch, +} from './web/index.mjs'; + +export type { + BinaryInput, + BinaryOperationJob, + BinaryOperationOptions, + BinaryOperationProgress, + ClassifiedPatchError, + PatchErrorCategory, + PatchFormat, + PatchInspectionOptions, + PatchMetadata, + PatchStructuralIssue, + PatchVerificationResult, +} from './web/index.mjs'; diff --git a/packages/web/index.mjs b/packages/web/index.mjs new file mode 100644 index 0000000..203055b --- /dev/null +++ b/packages/web/index.mjs @@ -0,0 +1,11 @@ +export { + classifyPatchError, + diffBytes, + inspectPatch, + patchBytes, + startDiff, + startDiffBytes, + startPatch, + startPatchBytes, + verifyPatch, +} from './web/index.mjs'; diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..aa00193 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "bs-diff-patch-web", + "version": "0.5.0", + "description": "Browser Web Worker and WebAssembly binary diff, patch, and verified delta toolkit", + "license": "MIT", + "private": true, + "type": "module", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "types": "./index.d.mts", + "exports": { + ".": { + "types": "./index.d.mts", + "import": "./index.mjs", + "default": "./index.mjs" + }, + "./toolkit": { + "types": "./toolkit/index.d.ts", + "import": "./toolkit/index.mjs", + "default": "./toolkit/index.mjs" + }, + "./package.json": "./package.json" + }, + "keywords": [ + "bsdiff", + "binary-diff", + "binary-patch", + "browser", + "wasm", + "web-worker" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/JimmyDaddy/react-native-bs-diff-patch.git" + }, + "bugs": { + "url": "https://github.com/JimmyDaddy/react-native-bs-diff-patch/issues" + }, + "homepage": "https://bs-dff-patch.corerobin.com", + "scripts": { + "build": "node ../../scripts/build-web-package.mjs", + "check": "node ../../scripts/check-web-package-contract.mjs", + "prepack": "node ../../scripts/build-web-package.mjs --refuse-source-pack" + } +} diff --git a/scripts/build-web-package.mjs b/scripts/build-web-package.mjs new file mode 100644 index 0000000..2deedac --- /dev/null +++ b/scripts/build-web-package.mjs @@ -0,0 +1,73 @@ +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { + packageExports, + packageFileMappings, + repositoryDirectory, + sourcePackageDirectory, + stagingPackageDirectory, +} from './web-package-layout.mjs'; + +const refuseSourcePack = process.argv.includes('--refuse-source-pack'); + +if (refuseSourcePack) { + throw new Error( + 'packages/web is a private package template. Run node scripts/build-web-package.mjs, then cd build/web-package && npm pack.' + ); +} + +const sourceManifest = JSON.parse( + await readFile(path.join(sourcePackageDirectory, 'package.json'), 'utf8') +); + +if (sourceManifest.private !== true) { + throw new Error('packages/web/package.json must remain private'); +} +if (sourceManifest.name !== 'bs-diff-patch-web') { + throw new Error('packages/web/package.json must name bs-diff-patch-web'); +} + +const stagingManifest = { + name: sourceManifest.name, + version: sourceManifest.version, + description: sourceManifest.description, + license: sourceManifest.license, + type: 'module', + types: './index.d.mts', + exports: packageExports, + files: [ + 'index.mjs', + 'index.d.mts', + 'web', + 'toolkit', + 'LICENSE', + 'THIRD_PARTY_NOTICES.txt', + 'README.md', + 'README.zh-CN.md', + ], + keywords: sourceManifest.keywords, + repository: sourceManifest.repository, + bugs: sourceManifest.bugs, + homepage: sourceManifest.homepage, + publishConfig: sourceManifest.publishConfig, +}; + +await rm(stagingPackageDirectory, { recursive: true, force: true }); +await mkdir(stagingPackageDirectory, { recursive: true }); + +for (const mapping of packageFileMappings) { + const source = path.join(repositoryDirectory, mapping.from); + const destination = path.join(stagingPackageDirectory, mapping.to); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); +} + +await writeFile( + path.join(stagingPackageDirectory, 'package.json'), + `${JSON.stringify(stagingManifest, null, 2)}\n` +); + +console.log( + `Built ${stagingManifest.name}@${stagingManifest.version} in build/web-package` +); diff --git a/scripts/check-package-contract.mjs b/scripts/check-package-contract.mjs index 55f0474..0d0de51 100644 --- a/scripts/check-package-contract.mjs +++ b/scripts/check-package-contract.mjs @@ -3,6 +3,8 @@ import { access, readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { assertDeclarationValueExportsMatchRuntime } from './declaration-contract.mjs'; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const manifest = JSON.parse( await readFile(path.join(root, 'package.json'), 'utf8') @@ -88,13 +90,7 @@ for (const [entry, declaration] of [ ]) { const api = await import(pathToFileURL(path.join(root, entry)).href); const types = await readFile(path.join(root, declaration), 'utf8'); - for (const name of Object.keys(api)) { - assert.match( - types, - new RegExp(`export (?:declare )?(?:function|class|const) ${name}\\b`), - `${entry} export ${name} must have a public declaration` - ); - } + assertDeclarationValueExportsMatchRuntime(api, types, declaration); } console.log( `Package ${manifest.version}: exports, public declarations, assets and Node-free browser graph passed` diff --git a/scripts/check-web-package-contract.mjs b/scripts/check-web-package-contract.mjs new file mode 100644 index 0000000..91da7b7 --- /dev/null +++ b/scripts/check-web-package-contract.mjs @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + assertDeclarationValueExportsMatchRuntime, + declarationExports, +} from './declaration-contract.mjs'; +import { + packageExports, + packageFileMappings, + repositoryDirectory, + sourcePackageDirectory, + stagedPackageFiles, + stagingPackageDirectory, + webRuntimeModuleFiles, +} from './web-package-layout.mjs'; + +async function listFiles(directory, relative = '') { + const entries = await readdir(path.join(directory, relative), { + withFileTypes: true, + }); + const files = []; + for (const entry of entries) { + const entryRelative = path.join(relative, entry.name).replaceAll('\\', '/'); + if (entry.isDirectory()) { + files.push(...(await listFiles(directory, entryRelative))); + } else if (entry.isFile()) { + files.push(entryRelative); + } else { + throw new Error(`Unexpected non-file package entry: ${entryRelative}`); + } + } + return files; +} + +const numericSemverIdentifier = '(?:0|[1-9]\\d*)'; +const prereleaseSemverIdentifier = `(?:${numericSemverIdentifier}|\\d*[A-Za-z-][0-9A-Za-z-]*)`; +const semverVersion = new RegExp( + `^${numericSemverIdentifier}\\.${numericSemverIdentifier}\\.${numericSemverIdentifier}` + + `(?:-${prereleaseSemverIdentifier}(?:\\.${prereleaseSemverIdentifier})*)?` + + '(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$' +); + +function parsePackResult(output) { + const starts = [0]; + for (let index = 0; index < output.length; index += 1) { + if (output[index] === '\n') { + starts.push(index + 1); + } + } + for (const start of starts.reverse()) { + const opening = output[start]; + if (opening !== '[' && opening !== '{') continue; + const stack = [opening === '[' ? ']' : '}']; + let escaped = false; + let inString = false; + for (let index = start + 1; index < output.length; index += 1) { + const character = output[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + } else if (character === '[') { + stack.push(']'); + } else if (character === '{') { + stack.push('}'); + } else if (character === stack.at(-1)) { + stack.pop(); + if (stack.length === 0) { + try { + const result = JSON.parse(output.slice(start, index + 1)); + if (Array.isArray(result)) return result; + if (Array.isArray(result.files)) return [result]; + const records = Object.values(result); + if (records.every((record) => Array.isArray(record?.files))) { + return records; + } + } catch { + // Try an earlier JSON-looking line instead. + } + break; + } + } + } + } + throw new Error(`npm pack did not return JSON:\n${output}`); +} + +async function checkModuleGraph(entry) { + const visited = new Set(); + async function visit(relative) { + if (visited.has(relative)) return; + visited.add(relative); + const source = await readFile( + path.join(stagingPackageDirectory, relative), + 'utf8' + ); + assert.doesNotMatch( + source, + /['"]node:|\bNODEFS\b|\bENVIRONMENT_IS_NODE\b|\b__dirname\b|\brequire\s*\(|(?:from\s+|import\s*)['"]react-native(?:\/|['"])/, + `Browser package dependency is not browser-only: ${relative}` + ); + for (const match of source.matchAll(/['"](\.\.?\/[^'"\n]+\.mjs)['"]/g)) { + const dependency = path.posix.normalize( + path.posix.join(path.posix.dirname(relative), match[1]) + ); + await visit(dependency); + } + } + await visit(entry); + return visited; +} + +const sourceManifest = JSON.parse( + await readFile(path.join(sourcePackageDirectory, 'package.json'), 'utf8') +); +const stagingManifest = JSON.parse( + await readFile(path.join(stagingPackageDirectory, 'package.json'), 'utf8') +); + +assert.equal(sourceManifest.name, 'bs-diff-patch-web'); +assert.match( + sourceManifest.version, + semverVersion, + 'packages/web/package.json version must be a valid SemVer version' +); +assert.equal(sourceManifest.private, true); +assert.deepEqual(sourceManifest.exports, packageExports); +assert.deepEqual(sourceManifest.publishConfig, { + registry: 'https://registry.npmjs.org/', + access: 'public', +}); +const thirdPartyNotices = await readFile( + path.join(sourcePackageDirectory, 'THIRD_PARTY_NOTICES.txt'), + 'utf8' +); +for (const requiredNotice of [ + 'Copyright 2003-2005 Colin Percival', + 'Copyright 2012 Matthew Endsley', + 'bzip2/libbzip2 version 1.0.6 of 6 September 2010', + 'Copyright (c) 2010-2014 Emscripten authors', + 'Copyright © 2005-2020 Rich Felker, et al.', +]) { + assert.ok( + thirdPartyNotices.includes(requiredNotice), + `THIRD_PARTY_NOTICES.txt is missing: ${requiredNotice}` + ); +} +assert.deepEqual(sourceManifest.exports, stagingManifest.exports); +assert.equal(stagingManifest.private, undefined); +assert.equal(stagingManifest.scripts, undefined); +assert.equal(stagingManifest.types, './index.d.mts'); +assert.deepEqual(stagingManifest.publishConfig, sourceManifest.publishConfig); +assert.ok( + stagedPackageFiles.includes('THIRD_PARTY_NOTICES.txt'), + 'Standalone Web package must include its third-party notices' +); +for (const forbiddenField of [ + 'bin', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'react-native', +]) { + assert.equal( + stagingManifest[forbiddenField], + undefined, + `Staging manifest must not contain ${forbiddenField}` + ); +} +assert.equal(stagingManifest.name, sourceManifest.name); +assert.equal(stagingManifest.version, sourceManifest.version); +assert.equal(stagingManifest.type, 'module'); + +assert.deepEqual( + (await listFiles(stagingPackageDirectory)).sort(), + stagedPackageFiles, + 'Staging package file set changed' +); + +for (const mapping of packageFileMappings) { + assert.deepEqual( + await readFile(path.join(stagingPackageDirectory, mapping.to)), + await readFile(path.join(repositoryDirectory, mapping.from)), + `Staged ${mapping.to} differs from ${mapping.from}` + ); +} + +const webGraph = await checkModuleGraph('index.mjs'); +assert.deepEqual( + [...webGraph].sort(), + webRuntimeModuleFiles.slice().sort(), + 'Web export graph must contain exactly the browser runtime' +); +const toolkitGraph = await checkModuleGraph('toolkit/index.mjs'); +assert.deepEqual( + [...toolkitGraph], + ['toolkit/index.mjs'], + 'Toolkit export graph must be self-contained' +); + +for (const [entry, declaration, sourceDirectory] of [ + ['index.mjs', 'index.d.mts'], + ['toolkit/index.mjs', 'toolkit/index.d.ts', repositoryDirectory], +]) { + const staged = await import( + pathToFileURL(path.join(stagingPackageDirectory, entry)).href + ); + if (sourceDirectory) { + const source = await import( + pathToFileURL(path.join(sourceDirectory, entry)).href + ); + assert.deepEqual( + Object.keys(staged).sort(), + Object.keys(source).sort(), + `Staged ${entry} public API differs from source` + ); + } + const declarationSource = await readFile( + path.join(stagingPackageDirectory, declaration), + 'utf8' + ); + assertDeclarationValueExportsMatchRuntime( + staged, + declarationSource, + declaration + ); + if (entry === 'index.mjs') { + const facadeDeclarations = declarationExports( + declarationSource, + declaration + ); + for (const forbiddenExport of [ + 'diff', + 'patch', + 'NativeOperationJob', + 'NativeOperationOptions', + 'NativeOperationProgress', + ]) { + assert.ok( + !Object.hasOwn(staged, forbiddenExport) && + !facadeDeclarations.includes(forbiddenExport), + `Standalone Web facade must not export ${forbiddenExport}` + ); + } + } +} + +const packed = spawnSync( + 'npm', + ['pack', '--dry-run', '--ignore-scripts', '--json'], + { cwd: stagingPackageDirectory, encoding: 'utf8' } +); +if (packed.status !== 0) { + throw new Error( + `npm pack failed:\n${packed.stdout || ''}${packed.stderr || ''}` + ); +} +const packResult = parsePackResult(packed.stdout); +assert.equal(packResult.length, 1, 'npm pack returned an unexpected result'); +assert.deepEqual( + packResult[0].files.map((file) => file.path).sort(), + stagedPackageFiles, + 'npm tarball file set changed' +); + +console.log( + `Web package ${stagingManifest.name}@${stagingManifest.version}: browser graph, bytes, API types and tarball contract passed` +); diff --git a/scripts/declaration-contract.mjs b/scripts/declaration-contract.mjs new file mode 100644 index 0000000..b8df7dc --- /dev/null +++ b/scripts/declaration-contract.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; + +import ts from 'typescript'; + +function isExported(statement) { + return statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); +} + +function addBindingNames(bindingName, names) { + if (ts.isIdentifier(bindingName)) { + names.add(bindingName.text); + return; + } + for (const element of bindingName.elements) { + if (ts.isBindingElement(element)) addBindingNames(element.name, names); + } +} + +/** + * Return value-level exports from a declaration module. Type-only exports are + * intentionally excluded: this contract compares the JavaScript namespace to + * values that TypeScript consumers can import at runtime. + */ +export function declarationValueExports(source, filename) { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS + ); + const names = new Set(); + + for (const statement of file.statements) { + if (ts.isExportDeclaration(statement)) { + if (statement.isTypeOnly || !statement.exportClause) continue; + if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + if (!element.isTypeOnly) names.add(element.name.text); + } + } else if (ts.isNamespaceExport(statement.exportClause)) { + names.add(statement.exportClause.name.text); + } + continue; + } + + if (ts.isExportAssignment(statement)) { + names.add('default'); + continue; + } + if (!isExported(statement)) continue; + + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement)) && + statement.name + ) { + names.add(statement.name.text); + continue; + } + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + addBindingNames(declaration.name, names); + } + continue; + } + if (ts.isImportEqualsDeclaration(statement)) { + names.add(statement.name.text); + } + } + return [...names].sort(); +} + +/** Return every public declaration name, including type-only exports. */ +export function declarationExports(source, filename) { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS + ); + const names = new Set(); + + for (const statement of file.statements) { + if (ts.isExportDeclaration(statement)) { + if (!statement.exportClause) continue; + if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + names.add(element.name.text); + } + } else if (ts.isNamespaceExport(statement.exportClause)) { + names.add(statement.exportClause.name.text); + } + continue; + } + if (ts.isExportAssignment(statement)) { + names.add('default'); + continue; + } + if (!isExported(statement)) continue; + + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isModuleDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isImportEqualsDeclaration(statement)) && + statement.name + ) { + names.add(statement.name.text); + continue; + } + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + addBindingNames(declaration.name, names); + } + } + } + return [...names].sort(); +} + +export function assertDeclarationValueExportsMatchRuntime( + runtimeApi, + declarationSource, + declarationPath +) { + assert.deepEqual( + declarationValueExports(declarationSource, declarationPath), + Object.keys(runtimeApi).sort(), + `${declarationPath} value exports must exactly match its runtime module` + ); +} diff --git a/scripts/test-sdk-consumers.mjs b/scripts/test-sdk-consumers.mjs index dd874ce..f0db444 100644 --- a/scripts/test-sdk-consumers.mjs +++ b/scripts/test-sdk-consumers.mjs @@ -22,10 +22,26 @@ import puppeteer from 'puppeteer-core'; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const repositoryDirectory = path.resolve(scriptDirectory, '..'); +const consumerProfile = process.env.SDK_CONSUMER_PROFILE || 'react-native'; +if (!['react-native', 'web'].includes(consumerProfile)) { + throw new Error( + `Unsupported SDK_CONSUMER_PROFILE=${consumerProfile}; expected react-native or web` + ); +} +const standaloneWebProfile = consumerProfile === 'web'; +const primaryPackageName = standaloneWebProfile + ? 'bs-diff-patch-web' + : 'react-native-bs-diff-patch'; +const primaryImportPath = standaloneWebProfile + ? primaryPackageName + : `${primaryPackageName}/web`; const keepTemporaryDirectory = process.env.KEEP_SDK_CONSUMER_DIR === '1'; const temporaryDirectory = await realpath( await mkdtemp( - path.join(os.tmpdir(), 'react-native-bs-diff-patch-sdk-consumers-') + path.join( + os.tmpdir(), + `${primaryPackageName.replaceAll('/', '-')}-sdk-consumers-` + ) ) ); const chromeCandidates = [ @@ -134,7 +150,11 @@ async function prepareTarball() { return suppliedPath; } - const packageSpec = process.env.PACKAGE_SPEC; + const packageSpec = + process.env.PACKAGE_SPEC || + (standaloneWebProfile + ? path.join(repositoryDirectory, 'build', 'web-package') + : undefined); const metadata = parseTrailingJson( run('npm', [ 'pack', @@ -281,15 +301,26 @@ async function buildNativeFixture() { return executablePath; } -function createBrowserEntry({ importPath, includeToolkit, includeProgress }) { +function createBrowserEntry({ + importPath, + packageName, + includeToolkit, + includeProgress, + crossImportPath, +}) { const imports = ['diffBytes', 'inspectPatch', 'patchBytes', 'verifyPatch']; if (includeToolkit) { imports.push('startDiffBytes'); } const lines = [`import { ${imports.join(', ')} } from '${importPath}';`]; + if (crossImportPath) { + lines.push( + `import { diffBytes as crossDiffBytes, patchBytes as crossPatchBytes } from '${crossImportPath}';` + ); + } if (includeToolkit) { lines.push( - "import { canonicalJson, createPatchManifest } from 'react-native-bs-diff-patch/toolkit';" + `import { canonicalJson, createPatchManifest } from '${packageName}/toolkit';` ); } lines.push( @@ -350,6 +381,14 @@ function createBrowserEntry({ importPath, includeToolkit, includeProgress }) { ' if (canonicalJson({ b: 1, a: 2 }) !== "{\\"a\\":2,\\"b\\":1}" || manifest.version !== 1) throw new Error("Toolkit consumer assertions failed");' ); } + if (crossImportPath) { + lines.push( + ' const crossPatch = await crossDiffBytes(oldData, newData);', + ' const crossRestored = await patchBytes(oldData, crossPatch);', + ' const restoredByCross = await crossPatchBytes(oldData, patch);', + ' if (!sameBytes(crossRestored, newData) || !sameBytes(restoredByCross, newData)) throw new Error("Co-installed package patch compatibility assertions failed");' + ); + } lines.push( ' return {', ' patch: toBase64(patch),', @@ -372,9 +411,12 @@ function createBrowserEntry({ importPath, includeToolkit, includeProgress }) { async function writeConsumer({ name, packageSpec, + packageName, importPath, includeToolkit, includeProgress, + additionalPackageSpecs = [], + crossImportPath, }) { const directory = path.join(temporaryDirectory, name); await mkdir(path.join(directory, 'src'), { recursive: true }); @@ -411,82 +453,137 @@ async function writeConsumer({ ); await writeFile( path.join(directory, 'src', 'main.ts'), - createBrowserEntry({ importPath, includeToolkit, includeProgress }) + createBrowserEntry({ + importPath, + packageName, + includeToolkit, + includeProgress, + crossImportPath, + }) ); if (includeToolkit) { - await writeFile( - path.join(directory, 'src', 'browser-types.ts'), - [ - "import { startDiff as startRootDiff } from 'react-native-bs-diff-patch';", - "import { startDiff as startWebDiff, type BinaryOperationJob } from 'react-native-bs-diff-patch/web';", - 'const input = new Uint8Array([1, 2, 3]);', - 'const rootJob: BinaryOperationJob = startRootDiff(input, input);', - 'const webJob: BinaryOperationJob = startWebDiff(input, input);', - 'const rootResult: Promise = rootJob.result;', - 'const webResult: Promise = webJob.result;', - '// @ts-expect-error Browser root types must reject native path arguments.', - "startRootDiff('old.bin', 'new.bin', 'update.patch');", - '// @ts-expect-error The explicit /web entry must reject native path arguments.', - "startWebDiff('old.bin', 'new.bin', 'update.patch');", - 'void rootResult; void webResult;', - '', - ].join('\n') - ); - await writeFile( - path.join(directory, 'src', 'native-types.ts'), - [ - "import { startDiff } from 'react-native-bs-diff-patch';", - "import type { NativeOperationJob } from 'react-native-bs-diff-patch';", - "const nativeJob: NativeOperationJob = startDiff('old.bin', 'new.bin', 'update.patch');", - 'const nativeResult: Promise = nativeJob.result;', - 'void nativeResult;', - '', - ].join('\n') - ); - await writeFile( - path.join(directory, 'tsconfig.browser.json'), - `${JSON.stringify( - { - compilerOptions: { - customConditions: ['browser'], - lib: ['ES2022', 'DOM'], - module: 'NodeNext', - moduleResolution: 'NodeNext', - noEmit: true, - skipLibCheck: false, - strict: true, - target: 'ES2022', + if (standaloneWebProfile) { + await writeFile( + path.join(directory, 'src', 'web-types.ts'), + [ + '// @ts-expect-error The standalone Web root must not expose native file APIs.', + `import { diff, patch } from '${packageName}';`, + `import { startDiff, type BinaryOperationJob } from '${packageName}';`, + 'const input = new Uint8Array([1, 2, 3]);', + 'const webJob: BinaryOperationJob = startDiff(input, input);', + 'const webResult: Promise = webJob.result;', + '// @ts-expect-error The standalone Web root must reject native path arguments.', + "startDiff('old.bin', 'new.bin', 'update.patch');", + 'void webResult;', + '', + ].join('\n') + ); + for (const [filename, module, moduleResolution] of [ + ['tsconfig.nodenext.json', 'NodeNext', 'NodeNext'], + ['tsconfig.bundler.json', 'ESNext', 'Bundler'], + ]) { + await writeFile( + path.join(directory, filename), + `${JSON.stringify( + { + compilerOptions: { + lib: ['ES2022', 'DOM'], + module, + moduleResolution, + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ES2022', + }, + include: ['src/main.ts', 'src/web-types.ts'], + }, + null, + 2 + )}\n` + ); + } + } else { + await writeFile( + path.join(directory, 'src', 'browser-types.ts'), + [ + "import { startDiff as startRootDiff } from 'react-native-bs-diff-patch';", + "import { startDiff as startWebDiff, type BinaryOperationJob } from 'react-native-bs-diff-patch/web';", + 'const input = new Uint8Array([1, 2, 3]);', + 'const rootJob: BinaryOperationJob = startRootDiff(input, input);', + 'const webJob: BinaryOperationJob = startWebDiff(input, input);', + 'const rootResult: Promise = rootJob.result;', + 'const webResult: Promise = webJob.result;', + '// @ts-expect-error Browser root types must reject native path arguments.', + "startRootDiff('old.bin', 'new.bin', 'update.patch');", + '// @ts-expect-error The explicit /web entry must reject native path arguments.', + "startWebDiff('old.bin', 'new.bin', 'update.patch');", + 'void rootResult; void webResult;', + '', + ].join('\n') + ); + await writeFile( + path.join(directory, 'src', 'native-types.ts'), + [ + "import { startDiff } from 'react-native-bs-diff-patch';", + "import type { NativeOperationJob } from 'react-native-bs-diff-patch';", + "const nativeJob: NativeOperationJob = startDiff('old.bin', 'new.bin', 'update.patch');", + 'const nativeResult: Promise = nativeJob.result;', + 'void nativeResult;', + '', + ].join('\n') + ); + await writeFile( + path.join(directory, 'tsconfig.browser.json'), + `${JSON.stringify( + { + compilerOptions: { + customConditions: ['browser'], + lib: ['ES2022', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ES2022', + }, + include: ['src/main.ts', 'src/browser-types.ts'], }, - include: ['src/main.ts', 'src/browser-types.ts'], - }, - null, - 2 - )}\n` - ); - await writeFile( - path.join(directory, 'tsconfig.native.json'), - `${JSON.stringify( - { - compilerOptions: { - lib: ['ES2022', 'DOM'], - module: 'NodeNext', - moduleResolution: 'NodeNext', - noEmit: true, - skipLibCheck: false, - strict: true, - target: 'ES2022', + null, + 2 + )}\n` + ); + await writeFile( + path.join(directory, 'tsconfig.native.json'), + `${JSON.stringify( + { + compilerOptions: { + lib: ['ES2022', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ES2022', + }, + include: ['src/native-types.ts'], }, - include: ['src/native-types.ts'], - }, - null, - 2 - )}\n` - ); + null, + 2 + )}\n` + ); + } } run( 'npm', - ['install', '--no-audit', '--no-fund', '--prefer-offline', packageSpec], + [ + 'install', + '--no-audit', + '--no-fund', + '--prefer-offline', + packageSpec, + ...additionalPackageSpecs, + ], { cwd: directory } ); @@ -508,32 +605,28 @@ async function writeConsumer({ false, `${name} dependency tree must not contain React Native` ); + assert.equal( + Object.hasOwn(installedTree.dependencies || {}, 'react'), + false, + `${name} dependency tree must not contain React` + ); if (includeToolkit) { - const typecheck = run( - process.execPath, - [ - path.join(directory, 'node_modules/typescript/bin/tsc'), - '-p', - 'tsconfig.browser.json', - ], - { cwd: directory } - ); - assert.equal(typecheck, '', `${name} TypeScript consumer emitted output`); - const nativeTypecheck = run( - process.execPath, - [ - path.join(directory, 'node_modules/typescript/bin/tsc'), - '-p', - 'tsconfig.native.json', - ], - { cwd: directory } - ); - assert.equal( - nativeTypecheck, - '', - `${name} native TypeScript consumer emitted output` - ); + const typecheckConfigs = standaloneWebProfile + ? ['tsconfig.nodenext.json', 'tsconfig.bundler.json'] + : ['tsconfig.browser.json', 'tsconfig.native.json']; + for (const config of typecheckConfigs) { + const typecheck = run( + process.execPath, + [path.join(directory, 'node_modules/typescript/bin/tsc'), '-p', config], + { cwd: directory } + ); + assert.equal( + typecheck, + '', + `${name} TypeScript consumer emitted output for ${config}` + ); + } } const buildOutput = run( process.execPath, @@ -662,14 +755,29 @@ async function runBrowserConsumer(browser, directory, name) { async function assertPackContract(tarballPath) { const tarEntries = run('tar', ['-tf', tarballPath]); - for (const entry of [ - 'package/web/index.mjs', - 'package/web/index.d.mts', - 'package/web/worker.mjs', - 'package/web/bsdiffpatch.browser.mjs', - 'package/toolkit/index.mjs', - 'package/toolkit/index.d.ts', - ]) { + const requiredEntries = standaloneWebProfile + ? [ + 'package/THIRD_PARTY_NOTICES.txt', + 'package/index.mjs', + 'package/index.d.mts', + 'package/web/index.mjs', + 'package/web/index.d.mts', + 'package/web/worker.browser.mjs', + 'package/web/operations.browser.mjs', + 'package/web/operation-runtime.mjs', + 'package/web/bsdiffpatch.browser.mjs', + 'package/toolkit/index.mjs', + 'package/toolkit/index.d.ts', + ] + : [ + 'package/web/index.mjs', + 'package/web/index.d.mts', + 'package/web/worker.mjs', + 'package/web/bsdiffpatch.browser.mjs', + 'package/toolkit/index.mjs', + 'package/toolkit/index.d.ts', + ]; + for (const entry of requiredEntries) { assert.match( tarEntries, new RegExp(`^${entry}$`, 'm'), @@ -677,28 +785,80 @@ async function assertPackContract(tarballPath) { ); } assert.doesNotMatch(tarEntries, /^package\/web\/progress_bridge\.c$/m); + if (standaloneWebProfile) { + assert.doesNotMatch( + tarEntries, + /^package\/web\/(?:bsdiffpatch|worker)\.mjs$/m, + 'The standalone package must not include a Node-capable WebAssembly runtime' + ); + assert.doesNotMatch( + tarEntries, + /^package\/(?:action|android|bin|cpp|example|examples|ios|lib|node|scripts|src)(?:\/|$)/m, + 'The standalone package must not include React Native, native, Node, or source directories' + ); + assert.doesNotMatch( + tarEntries, + /^package\/web\/.*\.(?:c|cc|cpp|h)$/m, + 'The standalone package must not include browser C/C++ sources' + ); + } } -async function assertManifestContract(consumerDirectory, expectedVersion) { +async function assertManifestContract( + consumerDirectory, + expectedVersion, + packageName +) { const manifest = JSON.parse( await readFile( - path.join( - consumerDirectory, - 'node_modules/react-native-bs-diff-patch/package.json' - ), + path.join(consumerDirectory, 'node_modules', packageName, 'package.json'), 'utf8' ) ); assert.equal(manifest.version, expectedVersion); - assert.equal(manifest.exports['./web'].import, './web/index.mjs'); - assert.equal(manifest.exports['./web'].types, './web/index.d.mts'); + if (standaloneWebProfile) { + assert.equal(manifest.name, 'bs-diff-patch-web'); + assert.equal(manifest.types, './index.d.mts'); + assert.equal(manifest.exports['.'].import, './index.mjs'); + assert.equal(manifest.exports['.'].types, './index.d.mts'); + assert.equal(manifest.exports['.'].default, './index.mjs'); + assert.equal(Object.hasOwn(manifest.exports, './web'), false); + assert.equal(Object.hasOwn(manifest.exports, './node'), false); + assert.equal(Object.hasOwn(manifest.exports['.'], 'require'), false); + assert.deepEqual(Object.keys(manifest.exports).sort(), [ + '.', + './package.json', + './toolkit', + ]); + for (const field of [ + 'browser', + 'bin', + 'codegenConfig', + 'dependencies', + 'devDependencies', + 'main', + 'module', + 'optionalDependencies', + 'peerDependencies', + 'react-native', + ]) { + assert.equal( + manifest[field], + undefined, + `Standalone Web package must not declare ${field}` + ); + } + } else { + assert.equal(manifest.exports['./web'].import, './web/index.mjs'); + assert.equal(manifest.exports['./web'].types, './web/index.d.mts'); + assert.equal( + Object.hasOwn(manifest.exports['./web'], 'require'), + false, + 'The ESM-only /web entry must not claim CommonJS support' + ); + } assert.equal(manifest.exports['./toolkit'].import, './toolkit/index.mjs'); assert.equal(manifest.exports['./toolkit'].types, './toolkit/index.d.ts'); - assert.equal( - Object.hasOwn(manifest.exports['./web'], 'require'), - false, - 'The ESM-only /web entry must not claim CommonJS support' - ); assert.equal( Object.hasOwn(manifest.exports['./toolkit'], 'require'), false, @@ -744,7 +904,7 @@ try { const tarballPath = await prepareTarball(); await assertPackContract(tarballPath); const packedManifest = readPackedManifest(tarballPath); - assert.equal(packedManifest.name, 'react-native-bs-diff-patch'); + assert.equal(packedManifest.name, primaryPackageName); const tarballIntegrity = createHash('sha512') .update(await readFile(tarballPath)) .digest('base64'); @@ -752,17 +912,42 @@ try { const current = await writeConsumer({ name: 'current-vite', packageSpec: tarballPath, - importPath: 'react-native-bs-diff-patch/web', + packageName: primaryPackageName, + importPath: primaryImportPath, includeToolkit: true, includeProgress: true, }); - await assertManifestContract(current.directory, packedManifest.version); + await assertManifestContract( + current.directory, + packedManifest.version, + primaryPackageName + ); await assertNoNodeRuntimeInBuild(current.directory, current.buildOutput); + const coinstalled = standaloneWebProfile + ? await writeConsumer({ + name: 'coinstalled-rn-v050-vite', + packageSpec: tarballPath, + packageName: primaryPackageName, + importPath: primaryImportPath, + includeToolkit: false, + includeProgress: false, + additionalPackageSpecs: ['react-native-bs-diff-patch@0.5.0'], + crossImportPath: 'react-native-bs-diff-patch/web', + }) + : undefined; + if (coinstalled) { + await assertNoNodeRuntimeInBuild( + coinstalled.directory, + coinstalled.buildOutput + ); + } + const registryTarballPath = await prepareRegistry040Tarball(); const registry = await writeConsumer({ name: 'registry-v040-vite', packageSpec: registryTarballPath, + packageName: 'react-native-bs-diff-patch', importPath: 'react-native-bs-diff-patch', includeToolkit: false, includeProgress: false, @@ -778,13 +963,21 @@ try { ], }); let currentBrowser; + let coinstalledBrowser; let registryBrowser; try { currentBrowser = await runBrowserConsumer( browser, current.directory, - `${packedManifest.version} /web` + `${packedManifest.name}@${packedManifest.version}` ); + if (coinstalled) { + coinstalledBrowser = await runBrowserConsumer( + browser, + coinstalled.directory, + `${packedManifest.name}@${packedManifest.version} co-installed with React Native 0.5.0 /web` + ); + } registryBrowser = await runBrowserConsumer( browser, registry.directory, @@ -794,12 +987,12 @@ try { assert.deepEqual( fromBase64(await currentBrowser.applyPatch(registryBrowser.patch)), fromBase64(currentBrowser.target), - `${packedManifest.version} /web did not restore a registry 0.4.0 patch` + `${packedManifest.name}@${packedManifest.version} did not restore a registry 0.4.0 patch` ); assert.deepEqual( fromBase64(await registryBrowser.applyPatch(currentBrowser.patch)), fromBase64(registryBrowser.target), - `registry 0.4.0 did not restore a ${packedManifest.version} /web patch` + `registry 0.4.0 did not restore a ${packedManifest.name}@${packedManifest.version} patch` ); const nativeCli = await buildNativeFixture(); @@ -842,7 +1035,7 @@ try { ) ), await readFile(targetPath), - `${packedManifest.version} /web did not restore a native-generated patch` + `${packedManifest.name}@${packedManifest.version} did not restore a native-generated patch` ); assert.deepEqual( fromBase64( @@ -855,12 +1048,15 @@ try { ); } finally { await registryBrowser?.close(); + await coinstalledBrowser?.close(); await currentBrowser?.close(); await browser.close(); } console.log( - `SDK consumers passed: version=${ + `SDK consumers passed: profile=${consumerProfile} package=${ + packedManifest.name + }@${ packedManifest.version } tarball=${tarballPath} sha512-${tarballIntegrity} registry040=${registry040Integrity} retained=${ keepTemporaryDirectory ? temporaryDirectory : 'no' @@ -869,6 +1065,8 @@ try { console.log( `SDK browser evidence: csp=${browserCsp} currentResources=${JSON.stringify( currentBrowser.resourceUrls + )} coinstalledResources=${JSON.stringify( + coinstalledBrowser?.resourceUrls || [] )} registryResources=${JSON.stringify(registryBrowser.resourceUrls)}` ); } finally { diff --git a/scripts/test-web-package-consumers.mjs b/scripts/test-web-package-consumers.mjs new file mode 100644 index 0000000..7aabef7 --- /dev/null +++ b/scripts/test-web-package-consumers.mjs @@ -0,0 +1,11 @@ +if ( + process.env.SDK_CONSUMER_PROFILE && + process.env.SDK_CONSUMER_PROFILE !== 'web' +) { + throw new Error( + `test-web-package-consumers.mjs requires SDK_CONSUMER_PROFILE=web, received ${process.env.SDK_CONSUMER_PROFILE}` + ); +} + +process.env.SDK_CONSUMER_PROFILE = 'web'; +await import('./test-sdk-consumers.mjs'); diff --git a/scripts/test-web-package-registry.mjs b/scripts/test-web-package-registry.mjs new file mode 100644 index 0000000..e7545ee --- /dev/null +++ b/scripts/test-web-package-registry.mjs @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict'; +import { gzip } from 'node:zlib'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { + hashTarball, + isValidVersion, + PACKAGE_NAME, + registryMetadataUrl, + registryTarballUrl, + statusTarball, + verifyTarball, +} from './web-package-registry.mjs'; + +const gzipAsync = promisify(gzip); +const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'bs-diff-patch-web-registry-') +); +const sourceManifest = JSON.parse( + await readFile( + new URL('../packages/web/package.json', import.meta.url), + 'utf8' + ) +); +assert.equal(sourceManifest.name, PACKAGE_NAME); +assert.equal(isValidVersion(sourceManifest.version), true); +assert.equal(isValidVersion('1.0.0+001'), true); +assert.equal(isValidVersion('1.0.0-01abc'), true); +assert.equal(isValidVersion('1.0.0-01'), false); +assert.equal(isValidVersion('1.0'), false); + +function tarHeader(name, size, type = 48) { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + header.write( + `000000${size.toString(8).padStart(5, '0')}\0`, + 124, + 12, + 'ascii' + ); + header.write('0000644\0', 100, 8, 'ascii'); + header.write('0000000\0', 108, 8, 'ascii'); + header.write('0000000\0', 116, 8, 'ascii'); + header[156] = type; + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + return header; +} + +async function createTarball(manifest, extraBytes = Buffer.from('fixture')) { + const packageJson = Buffer.from(`${JSON.stringify(manifest)}\n`); + const padding = (value) => Buffer.alloc((512 - (value.length % 512)) % 512); + const archive = Buffer.concat([ + tarHeader('package/package.json', packageJson.length), + packageJson, + padding(packageJson), + tarHeader('package/fixture.bin', extraBytes.length), + extraBytes, + padding(extraBytes), + Buffer.alloc(1024), + ]); + return gzipAsync(archive); +} + +function jsonResponse(metadata) { + return { + status: 200, + async json() { + return metadata; + }, + }; +} + +function bytesResponse(bytes) { + const value = Buffer.from(bytes); + return { + status: 200, + async arrayBuffer() { + return value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength + ); + }, + }; +} + +function createFetchResponder({ + metadata, + tarball, + metadataStatus = 200, + tarballStatus = 200, + error, +}) { + const calls = []; + const fetchImpl = async (url, options) => { + assert.equal(options.redirect, 'error'); + assert.equal(typeof options.signal?.aborted, 'boolean'); + calls.push(url); + if (error) { + throw error; + } + if (url === registryMetadataUrl(manifest.name, manifest.version)) { + return metadataStatus === 200 + ? jsonResponse(metadata) + : { + status: metadataStatus, + async json() { + return {}; + }, + }; + } + if (url === registryTarballUrl(manifest.name, manifest.version)) { + return tarballStatus === 200 + ? bytesResponse(tarball) + : { + status: tarballStatus, + async arrayBuffer() { + return new ArrayBuffer(0); + }, + }; + } + throw new Error(`Unexpected registry URL: ${url}`); + }; + return { calls, fetchImpl }; +} + +const manifest = { + name: sourceManifest.name, + version: sourceManifest.version, + type: 'module', +}; +const tarball = await createTarball(manifest); +const tarballPath = path.join( + temporaryDirectory, + `${manifest.name}-${manifest.version}.tgz` +); +await writeFile(tarballPath, tarball); +const hashes = hashTarball(tarball); +const matchingMetadata = { + name: manifest.name, + version: manifest.version, + dist: { integrity: hashes.integrity }, +}; + +async function assertRejects(promise, pattern) { + await assert.rejects(promise, (error) => { + assert.match(error.message, pattern); + return true; + }); +} + +try { + { + const fake = createFetchResponder({ metadata: {}, metadataStatus: 404 }); + const result = await statusTarball(tarballPath, { + fetchImpl: fake.fetchImpl, + }); + assert.equal(result.published, false); + assert.equal(result.integrity, hashes.integrity); + assert.equal(result.sha256, hashes.sha256); + assert.deepEqual(fake.calls, [ + registryMetadataUrl(manifest.name, manifest.version), + ]); + } + + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ + metadata: {}, + error: new Error('offline'), + }).fetchImpl, + }), + /official npm registry.*offline/ + ); + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ metadata: {}, metadataStatus: 503 }) + .fetchImpl, + }), + /HTTP 503/ + ); + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ metadata: {}, metadataStatus: 403 }) + .fetchImpl, + }), + /HTTP 403/ + ); + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ + metadata: { name: manifest.name, version: manifest.version }, + }).fetchImpl, + }), + /missing dist\.integrity/ + ); + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ + metadata: { ...matchingMetadata, dist: { integrity: 'sha512-wrong' } }, + }).fetchImpl, + }), + /dist\.integrity does not match/ + ); + await assertRejects( + statusTarball(tarballPath, { + fetchImpl: createFetchResponder({ + metadata: { + ...matchingMetadata, + dependencies: { 'react-native': '^0.86.0' }, + }, + }).fetchImpl, + }), + /must not contain dependencies/ + ); + + { + const invalidTarball = await createTarball({ + ...manifest, + version: '1.0.0-01', + }); + const invalidPath = path.join( + temporaryDirectory, + 'bs-diff-patch-web-invalid-version.tgz' + ); + await writeFile(invalidPath, invalidTarball); + await assertRejects( + statusTarball(invalidPath, { + fetchImpl: createFetchResponder({ metadata: {}, metadataStatus: 404 }) + .fetchImpl, + }), + /invalid semver version/ + ); + } + + { + const fake = createFetchResponder({ + metadata: matchingMetadata, + tarball: Buffer.from('wrong tarball'), + }); + await assertRejects( + verifyTarball(tarballPath, { fetchImpl: fake.fetchImpl }), + /does not match the candidate/ + ); + assert.deepEqual(fake.calls, [ + registryMetadataUrl(manifest.name, manifest.version), + registryTarballUrl(manifest.name, manifest.version), + ]); + } + + { + const fake = createFetchResponder({ + metadata: matchingMetadata, + tarball, + }); + const result = await statusTarball(tarballPath, { + fetchImpl: fake.fetchImpl, + }); + assert.equal(result.published, true); + assert.equal(result.provenance, false); + const verified = await verifyTarball(tarballPath, { + fetchImpl: fake.fetchImpl, + }); + assert.equal(verified.published, true); + assert.equal(verified.downloadedIntegrity, hashes.integrity); + assert.equal(verified.provenance, false); + await assertRejects( + verifyTarball(tarballPath, { + fetchImpl: fake.fetchImpl, + requireProvenance: true, + }), + /does not expose an SLSA provenance predicate/ + ); + } + + { + const fake = createFetchResponder({ + metadata: { + ...matchingMetadata, + dist: { + integrity: hashes.integrity, + attestations: { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }, + }, + }, + tarball, + }); + const verified = await verifyTarball(tarballPath, { + fetchImpl: fake.fetchImpl, + requireProvenance: true, + }); + assert.equal(verified.provenance, true); + assert.equal( + verified.provenancePredicateType, + 'https://slsa.dev/provenance/v1' + ); + } + + { + const dependencyTarball = await createTarball({ + ...manifest, + dependencies: { 'react-native': '^0.86.0' }, + }); + const dependencyPath = path.join( + temporaryDirectory, + 'bs-diff-patch-web-with-dependency.tgz' + ); + await writeFile(dependencyPath, dependencyTarball); + const fake = createFetchResponder({ + metadata: { + name: manifest.name, + version: manifest.version, + dist: { integrity: hashTarball(dependencyTarball).integrity }, + }, + tarball: dependencyTarball, + }); + await assertRejects( + verifyTarball(dependencyPath, { fetchImpl: fake.fetchImpl }), + /must not contain dependencies/ + ); + } + + console.log('web package registry checks passed'); +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/web-package-layout.mjs b/scripts/web-package-layout.mjs new file mode 100644 index 0000000..e89467b --- /dev/null +++ b/scripts/web-package-layout.mjs @@ -0,0 +1,80 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const repositoryDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +export const sourcePackageDirectory = path.join( + repositoryDirectory, + 'packages', + 'web' +); +export const stagingPackageDirectory = path.join( + repositoryDirectory, + 'build', + 'web-package' +); + +export const runtimeFiles = [ + 'index.mjs', + 'index.d.mts', + 'web/index.mjs', + 'web/worker.browser.mjs', + 'web/operations.browser.mjs', + 'web/operation-runtime.mjs', + 'web/bsdiffpatch.browser.mjs', + 'web/index.d.mts', + 'toolkit/index.mjs', + 'toolkit/index.d.ts', +]; + +export const webRuntimeModuleFiles = [ + 'index.mjs', + 'web/index.mjs', + 'web/worker.browser.mjs', + 'web/operations.browser.mjs', + 'web/operation-runtime.mjs', + 'web/bsdiffpatch.browser.mjs', +]; + +export const packageFileMappings = [ + { from: 'packages/web/index.mjs', to: 'index.mjs' }, + { from: 'packages/web/index.d.mts', to: 'index.d.mts' }, + ...runtimeFiles + .filter( + (relative) => + relative.startsWith('web/') || relative.startsWith('toolkit/') + ) + .map((relative) => ({ from: relative, to: relative })), + { from: 'LICENSE', to: 'LICENSE' }, + { + from: 'packages/web/THIRD_PARTY_NOTICES.txt', + to: 'THIRD_PARTY_NOTICES.txt', + }, + { from: 'packages/web/README.md', to: 'README.md' }, + { from: 'packages/web/README.zh-CN.md', to: 'README.zh-CN.md' }, +]; + +export const stagedPackageFiles = [ + 'LICENSE', + 'THIRD_PARTY_NOTICES.txt', + 'README.md', + 'README.zh-CN.md', + 'package.json', + ...runtimeFiles, +].sort(); + +export const packageExports = { + '.': { + types: './index.d.mts', + import: './index.mjs', + default: './index.mjs', + }, + './toolkit': { + types: './toolkit/index.d.ts', + import: './toolkit/index.mjs', + default: './toolkit/index.mjs', + }, + './package.json': './package.json', +}; diff --git a/scripts/web-package-registry.mjs b/scripts/web-package-registry.mjs new file mode 100644 index 0000000..e5e4fc2 --- /dev/null +++ b/scripts/web-package-registry.mjs @@ -0,0 +1,509 @@ +import { createHash } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const PACKAGE_NAME = 'bs-diff-patch-web'; +export const REGISTRY_BASE_URL = 'https://registry.npmjs.org'; +export const SLSA_PROVENANCE_PREDICATE = 'https://slsa.dev/provenance/v1'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryDirectory = path.resolve(scriptDirectory, '..'); +const sourceManifestPath = path.join( + repositoryDirectory, + 'packages', + 'web', + 'package.json' +); + +export class RegistryCheckError extends Error { + constructor(message) { + super(message); + this.name = 'RegistryCheckError'; + } +} + +function fail(message) { + throw new RegistryCheckError(message); +} + +function parseTarString(value) { + return value.toString('utf8').replace(/\0.*$/s, ''); +} + +function parseTarSize(header) { + const value = parseTarString(header.subarray(124, 136)).trim(); + if (!/^[0-7]+$/.test(value)) { + fail('The package tarball contains an invalid tar entry size.'); + } + return Number.parseInt(value, 8); +} + +function extractPackageJson(tarballBytes) { + let archive; + try { + archive = gunzipSync(tarballBytes); + } catch (error) { + fail( + `Unable to read the package tarball gzip stream: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + for (let offset = 0; offset + 512 <= archive.length; ) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + + const name = parseTarString(header.subarray(0, 100)); + const prefix = parseTarString(header.subarray(345, 500)); + const entryName = prefix ? `${prefix}/${name}` : name; + const size = parseTarSize(header); + const contentStart = offset + 512; + const contentEnd = contentStart + size; + if (contentEnd > archive.length) { + fail('The package tarball contains a truncated tar entry.'); + } + + const type = header[156]; + if (entryName === 'package/package.json' && (type === 0 || type === 48)) { + let manifest; + try { + manifest = JSON.parse( + archive.subarray(contentStart, contentEnd).toString('utf8') + ); + } catch (error) { + fail( + `The package tarball contains invalid package/package.json: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if ( + !manifest || + typeof manifest !== 'object' || + Array.isArray(manifest) + ) { + fail('package/package.json must contain a JSON object.'); + } + return manifest; + } + + offset = contentStart + Math.ceil(size / 512) * 512; + } + + fail('The package tarball does not contain package/package.json.'); +} + +function isValidSemverIdentifier(value, { allowNumericLeadingZero } = {}) { + if (!/^[0-9A-Za-z-]+$/.test(value)) { + return false; + } + if (allowNumericLeadingZero || !/^\d+$/.test(value)) { + return true; + } + return value === '0' || !/^0\d/.test(value); +} + +export function isValidVersion(version) { + if (typeof version !== 'string') { + return false; + } + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec( + version + ); + if (!match) { + return false; + } + const prereleaseValid = + !match[4] || + match[4].split('.').every((part) => isValidSemverIdentifier(part)); + const buildValid = + !match[5] || + match[5] + .split('.') + .every((part) => + isValidSemverIdentifier(part, { allowNumericLeadingZero: true }) + ); + return prereleaseValid && buildValid; +} + +function readSourceManifest() { + return readFile(sourceManifestPath, 'utf8').then((source) => { + let manifest; + try { + manifest = JSON.parse(source); + } catch (error) { + fail( + `Unable to read packages/web/package.json: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + fail('packages/web/package.json must contain a JSON object.'); + } + if (manifest.name !== PACKAGE_NAME) { + fail(`packages/web/package.json must name ${PACKAGE_NAME}.`); + } + if (!isValidVersion(manifest.version)) { + fail('packages/web/package.json has an invalid semver version.'); + } + return manifest; + }); +} + +function assertCandidateManifest(manifest, sourceManifest) { + if (manifest.name !== PACKAGE_NAME) { + fail(`The package tarball must name ${PACKAGE_NAME}.`); + } + if (!isValidVersion(manifest.version)) { + fail('The package tarball has an invalid semver version.'); + } + if (manifest.version !== sourceManifest.version) { + fail( + `The package tarball version ${String( + manifest.version + )} does not match packages/web/package.json version ${ + sourceManifest.version + }.` + ); + } +} + +export function hashTarball(tarballBytes) { + const bytes = Buffer.from(tarballBytes); + return { + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +export async function readCandidateTarball(tarballPath) { + let tarballBytes; + try { + tarballBytes = await readFile(tarballPath); + } catch (error) { + fail( + `Unable to read candidate tarball ${tarballPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const manifest = extractPackageJson(tarballBytes); + const sourceManifest = await readSourceManifest(); + assertCandidateManifest(manifest, sourceManifest); + assertStandaloneManifest(manifest); + return { + bytes: tarballBytes, + manifest, + name: manifest.name, + version: manifest.version, + ...hashTarball(tarballBytes), + }; +} + +export function registryMetadataUrl(name, version) { + return `${REGISTRY_BASE_URL}/${encodeURIComponent(name)}/${encodeURIComponent( + version + )}`; +} + +export function registryTarballUrl(name, version) { + return `${REGISTRY_BASE_URL}/${encodeURIComponent( + name + )}/-/${encodeURIComponent(`${name}-${version}.tgz`)}`; +} + +function getFetch(fetchImpl) { + const candidate = fetchImpl || globalThis.fetch; + if (typeof candidate !== 'function') { + fail('This Node.js runtime does not provide fetch.'); + } + return candidate; +} + +async function fetchJson(url, fetchImpl) { + let response; + try { + response = await getFetch(fetchImpl)(url, { + headers: { accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }); + } catch (error) { + fail( + `Unable to query the official npm registry: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!response || typeof response.status !== 'number') { + fail('The npm registry returned an invalid response.'); + } + if (response.status === 404) { + return undefined; + } + if (response.status < 200 || response.status >= 300) { + fail(`The npm registry query failed with HTTP ${response.status}.`); + } + + let metadata; + try { + metadata = await response.json(); + } catch (error) { + fail( + `The npm registry returned invalid metadata: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + fail('The npm registry returned invalid metadata.'); + } + return metadata; +} + +function assertRegistryMetadata(metadata, candidate) { + if ( + metadata.name !== candidate.name || + metadata.version !== candidate.version + ) { + fail( + 'The npm registry metadata name/version does not match the candidate.' + ); + } + if ( + !metadata.dist || + typeof metadata.dist !== 'object' || + typeof metadata.dist.integrity !== 'string' + ) { + fail('The npm registry metadata is missing dist.integrity.'); + } + if (metadata.dist.integrity !== candidate.integrity) { + fail( + 'The npm registry dist.integrity does not match the candidate tarball.' + ); + } +} + +export function extractProvenance(metadata) { + const predicateType = + metadata?.dist?.attestations?.provenance?.predicateType || null; + return { + predicateType, + slsa: predicateType === SLSA_PROVENANCE_PREDICATE, + }; +} + +export async function fetchRegistryMetadata(candidate, { fetchImpl } = {}) { + const metadata = await fetchJson( + registryMetadataUrl(candidate.name, candidate.version), + fetchImpl + ); + return metadata; +} + +function resultForCandidate(candidate, published, metadata) { + const provenance = metadata + ? extractProvenance(metadata) + : { predicateType: null, slsa: false }; + return { + name: candidate.name, + version: candidate.version, + integrity: candidate.integrity, + sha256: candidate.sha256, + published, + provenance: provenance.slsa, + provenancePredicateType: provenance.predicateType, + }; +} + +export async function statusTarball(tarballPath, { fetchImpl } = {}) { + const candidate = await readCandidateTarball(tarballPath); + const metadata = await fetchRegistryMetadata(candidate, { fetchImpl }); + if (!metadata) { + return resultForCandidate(candidate, false); + } + assertRegistryMetadata(metadata, candidate); + assertStandaloneManifest(metadata); + return resultForCandidate(candidate, true, metadata); +} + +async function fetchRegistryTarball(candidate, { fetchImpl } = {}) { + let response; + try { + response = await getFetch(fetchImpl)( + registryTarballUrl(candidate.name, candidate.version), + { + headers: { accept: 'application/octet-stream' }, + redirect: 'error', + signal: AbortSignal.timeout(30_000), + } + ); + } catch (error) { + fail( + `Unable to download the official npm registry tarball: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!response || typeof response.status !== 'number') { + fail('The npm registry tarball response is invalid.'); + } + if (response.status < 200 || response.status >= 300) { + fail( + `The npm registry tarball download failed with HTTP ${response.status}.` + ); + } + if (typeof response.arrayBuffer !== 'function') { + fail('The npm registry tarball response has no byte body.'); + } + try { + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + fail( + `Unable to read the npm registry tarball body: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function assertNoRuntimeDependencies(manifest) { + const dependencyFields = [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'bundledDependencies', + 'bundleDependencies', + ]; + for (const field of dependencyFields) { + const value = manifest[field]; + if ( + Array.isArray(value) + ? value.length > 0 + : value && Object.keys(value).length > 0 + ) { + fail(`The published package must not contain ${field}.`); + } + } + + const allDependencyFields = [...dependencyFields, 'devDependencies']; + for (const field of allDependencyFields) { + const value = manifest[field]; + if ( + value && + typeof value === 'object' && + Object.hasOwn(value, 'react-native') + ) { + fail( + `The published package must not reference react-native in ${field}.` + ); + } + } +} + +function assertStandaloneManifest(manifest) { + assertNoRuntimeDependencies(manifest); + for (const field of ['react-native', 'codegenConfig', 'bin']) { + if (Object.hasOwn(manifest, field)) { + fail(`The standalone package must not contain ${field}.`); + } + } +} + +function assertPublishedManifest(manifest, candidate) { + if ( + manifest.name !== candidate.name || + manifest.version !== candidate.version + ) { + fail('The downloaded package metadata does not match the candidate.'); + } + assertStandaloneManifest(manifest); +} + +export async function verifyTarball( + tarballPath, + { fetchImpl, requireProvenance = false } = {} +) { + const candidate = await readCandidateTarball(tarballPath); + const metadata = await fetchRegistryMetadata(candidate, { fetchImpl }); + if (!metadata) { + fail( + `The npm registry does not contain ${candidate.name}@${candidate.version}.` + ); + } + assertRegistryMetadata(metadata, candidate); + assertStandaloneManifest(metadata); + + const registryBytes = await fetchRegistryTarball(candidate, { fetchImpl }); + const registryHashes = hashTarball(registryBytes); + if ( + registryHashes.integrity !== candidate.integrity || + registryHashes.sha256 !== candidate.sha256 + ) { + fail('The downloaded npm registry tarball does not match the candidate.'); + } + assertPublishedManifest(extractPackageJson(registryBytes), candidate); + + const provenance = extractProvenance(metadata); + if (requireProvenance && !provenance.slsa) { + fail('The published package does not expose an SLSA provenance predicate.'); + } + return { + ...resultForCandidate(candidate, true, metadata), + downloadedIntegrity: registryHashes.integrity, + downloadedSha256: registryHashes.sha256, + }; +} + +export const status = statusTarball; +export const verify = verifyTarball; + +function usage() { + return [ + 'Usage: node scripts/web-package-registry.mjs status ', + ' or: node scripts/web-package-registry.mjs verify [--require-provenance]', + ].join('\n'); +} + +async function runCli(argv) { + const [command, tarballPath, option] = argv; + if (!command || !tarballPath || argv.length > 3) { + throw new RegistryCheckError(usage()); + } + if (command === 'status') { + if (option) { + throw new RegistryCheckError(usage()); + } + return statusTarball(tarballPath); + } + if (command === 'verify') { + if (option && option !== '--require-provenance') { + throw new RegistryCheckError(usage()); + } + return verifyTarball(tarballPath, { + requireProvenance: option === '--require-provenance', + }); + } + throw new RegistryCheckError(usage()); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + const result = await runCli(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +}