From 67523b23ad4a096b84b617c9187a90d5cf5e05c3 Mon Sep 17 00:00:00 2001
From: JimmyDaddy
Date: Mon, 31 Aug 2026 17:51:14 +0800
Subject: [PATCH 1/2] feat(sdk): add web and release toolkit SDKs
---
.github/workflows/ci.yml | 11 +-
.github/workflows/npm-publish.yml | 12 +-
.github/workflows/pages.yml | 1 +
CHANGELOG.md | 45 +
CONTRIBUTING.md | 29 +-
README.md | 108 ++-
README.zh-CN.md | 101 +-
action.yml | 50 +
action/index.mjs | 86 ++
android/CMakeLists.txt | 2 +
benchmarks/README.md | 2 +-
bin/react-native-bs-diff-patch.mjs | 302 ++++++
cpp/bsdiff.c | 17 +-
cpp/bsdiff40_converter.c | 257 ++++++
cpp/bsdiff40_converter.h | 16 +
cpp/bsdiffpatch_operation.h | 36 +-
cpp/bspatch.c | 278 +-----
cpp/bspatch_streaming.c | 348 +++++++
cpp/bspatch_streaming.h | 12 +
cpp/fuzz/bspatch_fuzzer.c | 1 +
cpp/tests/native_operations_test.c | 185 ++++
docs/README.md | 7 +-
docs/api-reference.md | 78 +-
docs/architecture.md | 33 +-
docs/development.md | 21 +-
docs/getting-started.md | 4 +
...ge-files-v04.md => large-files-roadmap.md} | 53 +-
docs/native-operations-v03.md | 8 +-
docs/platform-support.md | 17 +-
docs/troubleshooting.md | 6 +-
docs/verified-delta-pipeline.md | 141 +++
docs/web-sdk.md | 333 +++++++
docs/zh-CN/README.md | 6 +-
docs/zh-CN/api-reference.md | 71 +-
docs/zh-CN/architecture.md | 26 +-
docs/zh-CN/development.md | 17 +-
docs/zh-CN/getting-started.md | 3 +
...ge-files-v04.md => large-files-roadmap.md} | 42 +-
docs/zh-CN/native-operations-v03.md | 7 +-
docs/zh-CN/platform-support.md | 11 +-
docs/zh-CN/troubleshooting.md | 4 +-
docs/zh-CN/verified-delta-pipeline.md | 130 +++
docs/zh-CN/web-sdk.md | 281 ++++++
node/index.d.ts | 76 ++
node/index.mjs | 473 ++++++++++
package.json | 45 +-
scripts/benchmark-native.mjs | 1 +
scripts/build-site.mjs | 141 ++-
scripts/build-web-wasm.sh | 43 +-
scripts/check-package-contract.mjs | 101 ++
scripts/test-action.mjs | 66 ++
scripts/test-native-fuzz.sh | 2 +
scripts/test-native-operations.sh | 2 +
scripts/test-node-cli.mjs | 204 ++++
scripts/test-package-consumers.mjs | 150 ++-
scripts/test-sdk-consumers.mjs | 870 ++++++++++++++++++
scripts/test-site-browser.mjs | 99 +-
scripts/test-site.mjs | 41 +-
scripts/test-toolkit.mjs | 322 +++++++
scripts/test-web-browser.mjs | 9 +
scripts/test-web-metro.mjs | 13 +-
scripts/test-web.mjs | 264 +++++-
scripts/web-test.html | 87 +-
site/assets/planner.js | 354 +++++++
site/assets/site.css | 324 +++++++
site/assets/tools.js | 21 +-
site/index.html | 2 +
site/planner/index.html | 360 ++++++++
site/sitemap.xml | 6 +
site/tools/index.html | 2 +
src/index.ts | 98 ++
src/index.web.ts | 8 +
toolkit/index.d.ts | 127 +++
toolkit/index.mjs | 432 +++++++++
web/bsdiffpatch.browser.mjs | 2 +
web/bsdiffpatch.mjs | Bin 158653 -> 177719 bytes
web/index.d.mts | 65 +-
web/index.mjs | 347 +++++--
web/minimal-runtime-pre.js | 11 +
web/operation-runtime.mjs | 313 +++++++
web/operations.browser.mjs | 11 +
web/operations.mjs | 127 +--
web/progress_bridge.c | 98 ++
web/worker.browser.mjs | 47 +
web/worker.mjs | 8 +-
yarn.lock | 26 +-
86 files changed, 8258 insertions(+), 738 deletions(-)
create mode 100644 action.yml
create mode 100644 action/index.mjs
create mode 100755 bin/react-native-bs-diff-patch.mjs
create mode 100644 cpp/bsdiff40_converter.c
create mode 100644 cpp/bsdiff40_converter.h
create mode 100644 cpp/bspatch_streaming.c
create mode 100644 cpp/bspatch_streaming.h
rename docs/{large-files-v04.md => large-files-roadmap.md} (64%)
create mode 100644 docs/verified-delta-pipeline.md
create mode 100644 docs/web-sdk.md
rename docs/zh-CN/{large-files-v04.md => large-files-roadmap.md} (65%)
create mode 100644 docs/zh-CN/verified-delta-pipeline.md
create mode 100644 docs/zh-CN/web-sdk.md
create mode 100644 node/index.d.ts
create mode 100644 node/index.mjs
create mode 100644 scripts/check-package-contract.mjs
create mode 100644 scripts/test-action.mjs
create mode 100644 scripts/test-node-cli.mjs
create mode 100644 scripts/test-sdk-consumers.mjs
create mode 100644 scripts/test-toolkit.mjs
create mode 100644 site/assets/planner.js
create mode 100644 site/planner/index.html
create mode 100644 toolkit/index.d.ts
create mode 100644 toolkit/index.mjs
create mode 100644 web/bsdiffpatch.browser.mjs
create mode 100644 web/minimal-runtime-pre.js
create mode 100644 web/operation-runtime.mjs
create mode 100644 web/operations.browser.mjs
create mode 100644 web/progress_bridge.c
create mode 100644 web/worker.browser.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5fafaeb..de2633d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -108,10 +108,15 @@ jobs:
quality=true
site=true
;;
- scripts/prepare-package.mjs|scripts/test-package-consumers.mjs)
+ scripts/prepare-package.mjs|scripts/check-package-contract.mjs|scripts/test-package-consumers.mjs|scripts/test-sdk-consumers.mjs|scripts/sdk-consumer/**|examples/web-sdk/**)
quality=true
web=true
;;
+ bin/**|node/**|toolkit/**|action/**|action.yml|scripts/test-node-cli.mjs|scripts/test-toolkit.mjs|scripts/test-action.mjs)
+ quality=true
+ site=true
+ web=true
+ ;;
benchmarks/**|scripts/benchmark-web.mjs)
quality=true
site=true
@@ -451,7 +456,11 @@ jobs:
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
- name: Verify npm package contents
run: npm pack --dry-run --ignore-scripts
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index 0dcfc41..05df4f3 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -16,7 +16,7 @@ jobs:
publish-npm:
name: Publish to npm with OIDC
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 30
steps:
- name: Checkout the release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -86,7 +86,11 @@ jobs:
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
npm pack --dry-run --ignore-scripts
env:
CHROME_PATH: /usr/bin/google-chrome
@@ -109,3 +113,9 @@ jobs:
done
echo 'Published package did not expose provenance metadata in time.' >&2
exit 1
+
+ - name: Smoke test the published SDK
+ env:
+ PACKAGE_SPEC: react-native-bs-diff-patch@${{ steps.release.outputs.package_version }}
+ CHROME_PATH: /usr/bin/google-chrome
+ run: yarn test:sdk
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 4d98ea7..67c7b5d 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -8,6 +8,7 @@ on:
- 'docs/**'
- 'site/**'
- 'web/**'
+ - 'toolkit/**'
- 'scripts/build-site.mjs'
- 'scripts/test-site.mjs'
- 'scripts/test-site-browser.mjs'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c38d993..c9d602b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,51 @@ All notable changes to this project are documented in this file. Releases use
[Semantic Versioning](https://semver.org/) and are generated from Conventional
Commits by release-it.
+## [0.5.0](https://github.com/JimmyDaddy/react-native-bs-diff-patch/compare/v0.4.0...v0.5.0) (2026-08-31)
+
+### Added
+
+- add the explicit ESM `react-native-bs-diff-patch/web` entry for browser and
+ desktop WebView byte operations, including typed declarations, Worker jobs,
+ progress, cancellation, input/output limits, patch inspection, and
+ byte-for-byte verification;
+- publish a Node-free browser/Worker WebAssembly artifact alongside the
+ Node-compatible artifact, while keeping the existing root React Native,
+ Node, and CLI loading paths;
+- add the ESM `react-native-bs-diff-patch/toolkit` entry for normalized
+ manifests, multi-baseline bundles, canonical payloads, candidate selection,
+ error classification, and header-only inspection;
+- add tarball consumer checks for `/web` and `/toolkit`, production Vite
+ resource loading, offline browser execution, and TypeScript resolution;
+- add the Node release helpers, CLI, GitHub Action, and BSDIFF40 converter
+ needed to prepare verified release artifacts without changing the runtime's
+ `ENDSLEY/BSDIFF43` compatibility contract;
+- add bilingual WebView integration, packaging, lifecycle, CSP, resource
+ budget, trust-boundary, and release documentation.
+
+### Compatibility and release boundaries
+
+- keep the existing root CommonJS build, React Native conditional exports,
+ native path API, and Node-compatible `web/bsdiffpatch.mjs`;
+- keep `/web` and `/toolkit` ESM-only, and map the browser Worker graph to
+ `web/bsdiffpatch.browser.mjs` without a CDN or Node runtime fallback;
+- keep `BSDIFF40` as a header-inspection/conversion case only; runtime
+ generation and application remain `ENDSLEY/BSDIFF43`;
+- leave file authorization, persistence, and final replacement to downstream
+ desktop applications. Tauri WebView acceptance remains a downstream test,
+ and registry smoke checks remain a post-release validation step.
+
+### Security and compatibility scope
+
+- enforce a zero-byte output budget during C compressed output writes, and add
+ control-flow guards for malformed or non-progressing patch streams;
+- classify toolkit array cycles as `EINVALID_MANIFEST`, preserve a literal
+ `__proto__` object key during canonicalization, and reject invalid selection
+ budgets even when the requested baseline has no matching candidate;
+- retain first-match candidate selection rather than silently choosing the
+ smallest patch, while keeping the root React Native/Node compatibility paths
+ and the `ENDSLEY/BSDIFF43` runtime format unchanged.
+
## [0.4.0](https://github.com/JimmyDaddy/react-native-bs-diff-patch/compare/v0.3.0...v0.4.0) (2026-07-23)
### Features
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 08327f5..a09855e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -72,8 +72,14 @@ Web implementation changes should also pass:
yarn test:web
yarn test:web:browser
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
+resource graph, and real byte round trips. It does not use a workspace link or
+the registry's older package.
+
Documentation and site changes should pass:
```sh
@@ -119,12 +125,29 @@ Maintainers should run the quality gates, then create the release:
```sh
yarn prepare
+yarn test:sdk
yarn typecheck
yarn lint
yarn test --runInBand
-yarn release
+yarn pack --dry-run
+yarn release --no-increment
```
+`yarn prepare` runs the React Native Builder Bob output step, package
+preparation, and `scripts/check-package-contract.mjs`. The same contract check
+runs from the `prepack` lifecycle before a tarball is created. Run
+`node scripts/check-package-contract.mjs` directly when inspecting a prepared
+tree without rebuilding it. `yarn build:web` produces separate Node and
+browser/Worker WASM modules: `web/bsdiffpatch.mjs` keeps NODEFS for Node and
+the CLI, while `web/bsdiffpatch.browser.mjs` is the Node-free browser build.
+
+When `package.json` already contains the prepared version, use
+`yarn release --no-increment` so release-it does not bump it again. This command
+creates the release commit, tag, and GitHub Release; the GitHub Release then
+triggers the npm workflow. Run it only with explicit maintainer authorization.
+A local 0.5.0 tarball is not a registry release; do not describe it as
+published until the GitHub Release and npm provenance checks have completed.
+
The npm package already trusts the `JimmyDaddy/react-native-bs-diff-patch`
repository and the `npm-publish.yml` workflow. No npm-side configuration is
required for a release. The release tag must exactly match
@@ -145,6 +168,10 @@ 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`
+ from an isolated Vite consumer.
+- `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/`.
- `yarn site:test`: validate site structure and local links.
- `yarn site:test:browser`: verify the live Playground, docs, and mobile viewport.
diff --git a/README.md b/README.md
index d7e500c..b2d5eb7 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,8 @@
- Turn two versions of a file into a compact binary patch, then reconstruct the new file from the old file plus that patch.
- One compatible format across React Native Android, iOS, and Web.
+ A verified binary delta pipeline for React Native, Web, Node.js, and release CI.
+ Create compact patches, prove restored bytes, and plan multi-baseline delivery with one compatible format.
@@ -22,6 +22,7 @@
Documentation ·
Live Playground ·
Binary Patch Toolkit ·
+ Release Planner ·
中文说明 ·
npm
@@ -53,6 +54,8 @@ replaces live data.
browser.
- **Inspect and prove compatibility:** read patch metadata and verify restored
bytes through the same API shape on native and Web.
+- **Release-side tooling:** generate patches through `npx`, publish verified
+ manifests, and choose a patch or full-file fallback for each baseline.
## Platform overview
@@ -60,16 +63,46 @@ replaces live data.
| -------------- | -------------------------------------------- | -------------------------------------------------- |
| Input | Absolute file paths | `ArrayBuffer`, typed arrays, `DataView`, or `Blob` |
| Basic API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
-| Controlled API | `startDiff()` / `startPatch()` | `AbortSignal` and binary limits |
+| Controlled API | `startDiff()` / `startPatch()` | Binary `startDiff()` / `startPatch()` jobs |
| Verification | Paths via `inspectPatch()` / `verifyPatch()` | Binary values via the same APIs |
| Engine | Native C via JNI / ObjC++ | Same C core via WASM Worker |
+## Release-side CLI and bundles
+
+The same npm package includes a Node.js CLI for release pipelines:
+
+```sh
+npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch
+npx react-native-bs-diff-patch verify old.bin update.patch new.bin
+npx react-native-bs-diff-patch bundle \
+ --from releases/ \
+ --to dist/app.bin \
+ --out dist/update-bundle
+```
+
+`bundle` evaluates every baseline, retains efficient patches, adds a full-file
+fallback, and writes a canonical verified manifest suitable for CDN selection
+and detached signing. The CLI mounts host paths through NODEFS, while verified
+patch application uses the bounded streaming core. Try the workflow in the browser with the
+[Release Planner](https://bs-dff-patch.corerobin.com/planner/).
+
## Install
```sh
-npm install react-native-bs-diff-patch
+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:
+
+```sh
+npm install ./react-native-bs-diff-patch-0.5.0.tgz
```
+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 iOS, install Pods and rebuild the native application:
```sh
@@ -123,24 +156,35 @@ try {
## Web: first round trip
-```ts
-import { diffBytes, patchBytes } from 'react-native-bs-diff-patch';
+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.
-const oldBytes = await oldFile.arrayBuffer();
-const newBytes = await newFile.arrayBuffer();
+```ts
+import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web';
-const patchBytesValue = await diffBytes(oldBytes, newBytes, {
+const patchBytesValue = await diffBytes(oldFile, newFile, {
signal: abortController.signal,
maxInputBytes: 64 * 1024 * 1024,
+ onProgress: ({ phase, progress }) => {
+ renderProgress(phase, progress);
+ },
});
-const restoredBytes = await patchBytes(oldBytes, patchBytesValue, {
+const restoredBytes = await patchBytes(oldFile, patchBytesValue, {
maxOutputBytes: 64 * 1024 * 1024,
});
```
Web calls return a new `Uint8Array` and leave caller-owned buffers usable.
Aborted operations reject with `EABORTED`; configured binary limits reject with
-`ERESOURCE`.
+`ERESOURCE`. `Blob` and `File` inputs are mounted read-only in the Worker, so
+they do not need a full main-thread copy before the C core reads them.
+
+Use `startDiff()` / `startPatch()` with binary inputs on Web, or the explicit
+`startDiffBytes()` / `startPatchBytes()` aliases, when UI code needs a job
+object with `result`, `cancel()`, and real C-core progress events.
## Inspect and verify a patch
@@ -169,17 +213,18 @@ update manifest before replacing live data.
## API matrix
-| API | Android | iOS | Web |
-| --------------------------------------------- | ------- | --- | --- |
-| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No |
-| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No |
-| `startDiff(...)` / `startPatch(...)` | Yes | Yes | No |
-| `diffBytes(oldData, newData, options?)` | No | No | Yes |
-| `patchBytes(oldData, patchData, options?)` | No | No | Yes |
-| `inspectPatch(path or binary, options?)` | Yes | Yes | Yes |
-| `verifyPatch(old, patch, expected, options?)` | Yes | Yes | Yes |
-| Legacy architecture, while supplied by RN | Yes | Yes | N/A |
-| New Architecture / TurboModule | Yes | Yes | N/A |
+| API | Android | iOS | Web |
+| ---------------------------------------------- | ------- | ----- | ------ |
+| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No |
+| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No |
+| `startDiff(...)` / `startPatch(...)` | Paths | Paths | Binary |
+| `startDiffBytes(...)` / `startPatchBytes(...)` | No | No | Yes |
+| `diffBytes(oldData, newData, options?)` | No | No | Yes |
+| `patchBytes(oldData, patchData, options?)` | No | No | Yes |
+| `inspectPatch(path or binary, options?)` | Yes | Yes | Yes |
+| `verifyPatch(old, patch, expected, options?)` | Yes | Yes | Yes |
+| Legacy architecture, while supplied by RN | Yes | Yes | N/A |
+| New Architecture / TurboModule | Yes | Yes | N/A |
Unavailable platform APIs reject with `EUNSUPPORTED`; the package never
silently switches to a different input model.
@@ -191,27 +236,34 @@ silently switches to a different input model.
- Use unique native output paths and remove outputs you no longer need.
- Set product-specific resource limits. Binary diffing can use several times
the input size in peak memory.
-- Generate and apply patches with this library. Generic `BSDIFF40` patches are
- not interchangeable with `ENDSLEY/BSDIFF43` patches.
+- Runtime APIs accept `ENDSLEY/BSDIFF43`. Convert existing `BSDIFF40` files
+ offline with `npx react-native-bs-diff-patch convert legacy.patch -o
+compatible.patch`, then verify them before publishing.
See [Production recipes](./docs/recipes.md) for integrity checks, downloads,
cross-runtime exchange, error handling, and cleanup patterns.
## Verified compatibility
-CI compiles the Android and iOS APIs against React Native 0.73.11, 0.74.7, and
-0.86.0, and runs device-level New Architecture assertions on Android and iOS.
-Packed-consumer tests verify browser, ESM, CommonJS, Metro, and TypeScript
-resolution from the real npm package shape.
+CI covers Android and iOS API builds against React Native 0.73.11, 0.74.7, and
+0.86.0, and runs the configured New Architecture assertions. These checks do
+not constitute Tauri WebView acceptance or downstream physical-device
+acceptance. Packed-consumer tests verify browser, ESM, CommonJS, Metro, and
+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.
- [Getting started](./docs/getting-started.md)
- [API reference](./docs/api-reference.md)
- [Production recipes](./docs/recipes.md)
+- [Verified Delta Pipeline](./docs/verified-delta-pipeline.md)
- [Platform support](./docs/platform-support.md)
- [Architecture and patch format](./docs/architecture.md)
- [Controllable native operations](./docs/native-operations-v03.md)
+- [Large-file roadmap](./docs/large-files-roadmap.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [Development and verification](./docs/development.md)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 3993231..5803ab7 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -7,8 +7,8 @@
- 比较文件的两个版本,生成紧凑的二进制补丁;再用旧文件和补丁还原新文件。
- React Native Android、iOS 与 Web 共用同一种兼容格式。
+ 面向 React Native、Web、Node.js 与发布 CI 的可验证二进制增量工具链。
+ 用同一种兼容格式生成紧凑补丁、验证还原字节,并规划多基线分发。
@@ -22,6 +22,7 @@
中文文档 ·
在线 Playground ·
二进制补丁工具箱 ·
+ 发布规划器 ·
English ·
npm
@@ -49,6 +50,8 @@
暴露未完成的输出文件。
- **Web 无需补丁服务:** 差分和还原完全在浏览器本地执行。
- **检查并证明兼容性:** 原生与 Web 使用相同 API 读取补丁元数据,并验证还原字节。
+- **发布端工具:** 通过 `npx` 生成补丁、发布可验证 manifest,并为每个基线选择
+ 补丁或完整文件回退。
## 平台概览
@@ -56,16 +59,44 @@
| -------- | ----------------------------------------- | ----------------------------------------------- |
| 输入 | 绝对文件路径 | `ArrayBuffer`、TypedArray、`DataView` 或 `Blob` |
| 基础 API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
-| 可控 API | `startDiff()` / `startPatch()` | `AbortSignal` 与二进制大小限制 |
+| 可控 API | `startDiff()` / `startPatch()` | 二进制 `startDiff()` / `startPatch()` job |
| 验证能力 | 路径版 `inspectPatch()` / `verifyPatch()` | 相同 API 的二进制输入 |
| 执行核心 | JNI / ObjC++ 调用原生 C | WASM Worker 运行同一 C 核心 |
+## 发布端 CLI 与 bundle
+
+同一个 npm 包提供面向发布流程的 Node.js CLI:
+
+```sh
+npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch
+npx react-native-bs-diff-patch verify old.bin update.patch new.bin
+npx react-native-bs-diff-patch bundle \
+ --from releases/ \
+ --to dist/app.bin \
+ --out dist/update-bundle
+```
+
+`bundle` 会评估每个基线,保留高收益补丁,增加完整文件回退,并写出适合 CDN
+选择和 detached signature 的 canonical manifest。CLI 通过 NODEFS 挂载宿主路径,
+验证 patch 时使用有界流式核心。也可以直接在浏览器打开
+[发布规划器](https://bs-dff-patch.corerobin.com/zh-CN/planner/)体验完整流程。
+
## 安装
```sh
-npm install react-native-bs-diff-patch
+npm install react-native-bs-diff-patch@^0.5.0
+```
+
+明确的 `/web` 与 `/toolkit` 入口属于 0.5.0。发布前验证本地准备的包时,也可以改用其
+tarball:
+
+```sh
+npm install ./react-native-bs-diff-patch-0.5.0.tgz
```
+registry 的 0.4.x 包尚未包含这些子路径。资源图和消费者检查详见
+[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。
+
iOS 还需要安装 Pods,并重新构建原生应用:
```sh
@@ -119,23 +150,33 @@ try {
## Web:第一次往返
-```ts
-import { diffBytes, patchBytes } from 'react-native-bs-diff-patch';
+独立浏览器、Vite 和 Tauri 消费者应导入明确的
+`react-native-bs-diff-patch/web` ESM 入口;它提供字节 API,不需要 React Native。
+根包继续为已有应用保留 React Native 与 browser 条件解析。发布资源图和 CSP 见
+[Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md)。
-const oldBytes = await oldFile.arrayBuffer();
-const newBytes = await newFile.arrayBuffer();
+```ts
+import { diffBytes, patchBytes } from 'react-native-bs-diff-patch/web';
-const patchBytesValue = await diffBytes(oldBytes, newBytes, {
+const patchBytesValue = await diffBytes(oldFile, newFile, {
signal: abortController.signal,
maxInputBytes: 64 * 1024 * 1024,
+ onProgress: ({ phase, progress }) => {
+ renderProgress(phase, progress);
+ },
});
-const restoredBytes = await patchBytes(oldBytes, patchBytesValue, {
+const restoredBytes = await patchBytes(oldFile, patchBytesValue, {
maxOutputBytes: 64 * 1024 * 1024,
});
```
Web API 返回新的 `Uint8Array`,不会转移或失效调用方的缓冲区。主动取消以
-`EABORTED` 拒绝;命中二进制大小限制时以 `ERESOURCE` 拒绝。
+`EABORTED` 拒绝;命中二进制大小限制时以 `ERESOURCE` 拒绝。`Blob` 与 `File`
+会只读挂载到 Worker,不需要先在主线程生成完整副本。
+
+Web 端可以用二进制输入调用 `startDiff()` / `startPatch()`,也可以使用明确的
+`startDiffBytes()` / `startPatchBytes()` 别名,获得带 `result`、`cancel()` 和
+真实 C 核心进度事件的 job。
## 检查并验证补丁
@@ -163,17 +204,18 @@ if (!metadata.valid || !result.verified) {
## API 矩阵
-| API | Android | iOS | Web |
-| --------------------------------------------- | ------- | ------ | ------ |
-| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 |
-| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 |
-| `startDiff(...)` / `startPatch(...)` | 支持 | 支持 | 不支持 |
-| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 |
-| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 |
-| `inspectPatch(path 或 binary, options?)` | 支持 | 支持 | 支持 |
-| `verifyPatch(old, patch, expected, options?)` | 支持 | 支持 | 支持 |
-| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 |
-| 新架构 / TurboModule | 支持 | 支持 | 不适用 |
+| API | Android | iOS | Web |
+| ---------------------------------------------- | ------- | ------ | ------ |
+| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 |
+| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 |
+| `startDiff(...)` / `startPatch(...)` | 路径 | 路径 | 二进制 |
+| `startDiffBytes(...)` / `startPatchBytes(...)` | 不支持 | 不支持 | 支持 |
+| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 |
+| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 |
+| `inspectPatch(path 或 binary, options?)` | 支持 | 支持 | 支持 |
+| `verifyPatch(old, patch, expected, options?)` | 支持 | 支持 | 支持 |
+| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 |
+| 新架构 / TurboModule | 支持 | 支持 | 不适用 |
调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他输入
模型。
@@ -184,26 +226,31 @@ if (!metadata.valid || !result.verified) {
- 替换业务数据前,验证还原结果与目标文件完全一致。
- 原生端使用唯一输出路径,并清理不再需要的输出文件。
- 按业务设置资源限制;二进制差分的峰值内存可能达到输入大小的数倍。
-- 使用本库配套生成和应用补丁;通用 `BSDIFF40` 与
- `ENDSLEY/BSDIFF43` 不兼容。
+- 运行时只接受 `ENDSLEY/BSDIFF43`。已有 `BSDIFF40` 可以通过
+ `npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch`
+ 离线转换,并在发布前完成验证。
完整性校验、补丁下载、跨运行时交换、错误处理与清理模式见
[生产实践](./docs/zh-CN/recipes.md)。
## 已验证的兼容性
-CI 会使用 React Native 0.73.11、0.74.7 与 0.86.0 编译 Android 和 iOS API,
-并在 Android 与 iOS 上执行新架构设备级断言。真实 npm 包消费测试还覆盖 browser、
-ESM、CommonJS、Metro 与 TypeScript 解析。
+CI 覆盖使用 React Native 0.73.11、0.74.7 与 0.86.0 的 Android 和 iOS API 构建,
+并运行配置的新架构断言。这些检查不等同于 Tauri WebView 验收或下游真机验收。真实
+npm 包消费测试还覆盖 browser、ESM、CommonJS、Metro 与 TypeScript 解析。
## 完整文档
+- [Web 与桌面 WebView SDK](./docs/zh-CN/web-sdk.md) — 从 Vite 或 Tauri 使用明确的
+ `/web` 与 `/toolkit` ESM 入口,无需 React Native 或 Node sidecar。
- [快速开始](./docs/zh-CN/getting-started.md)
- [API 参考](./docs/zh-CN/api-reference.md)
- [生产实践](./docs/zh-CN/recipes.md)
+- [可验证增量发布工具链](./docs/zh-CN/verified-delta-pipeline.md)
- [平台支持](./docs/zh-CN/platform-support.md)
- [架构与补丁格式](./docs/zh-CN/architecture.md)
- [可控制的原生操作](./docs/zh-CN/native-operations-v03.md)
+- [大文件演进路线](./docs/zh-CN/large-files-roadmap.md)
- [常见问题与排障](./docs/zh-CN/troubleshooting.md)
- [开发与验证](./docs/zh-CN/development.md)
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..b138a30
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,50 @@
+name: Verified Delta Patch
+description: Create a verified BSDIFF43 patch and manifest for a release artifact
+author: JimmyDaddy
+
+inputs:
+ old-file:
+ description: Baseline release file
+ required: true
+ new-file:
+ description: Target release file
+ required: true
+ patch-file:
+ description: Output patch path
+ required: false
+ default: update.patch
+ manifest-file:
+ description: Output verified manifest path
+ required: false
+ default: patch-manifest.json
+ max-patch-ratio:
+ description: Full-file fallback threshold between 0 and 1
+ required: false
+ default: '0.85'
+ release-id:
+ description: Optional release identifier stored in the manifest
+ required: false
+
+outputs:
+ strategy:
+ description: patch when the delta is efficient, otherwise full
+ patch-file:
+ description: Generated patch path
+ manifest-file:
+ description: Generated manifest path
+ patch-bytes:
+ description: Generated patch size
+ target-bytes:
+ description: Target file size
+ patch-ratio:
+ description: Patch size divided by target size
+ savings-ratio:
+ description: Transfer ratio saved compared with the full target
+
+runs:
+ using: node24
+ main: action/index.mjs
+
+branding:
+ icon: package
+ color: blue
diff --git a/action/index.mjs b/action/index.mjs
new file mode 100644
index 0000000..ccc37fe
--- /dev/null
+++ b/action/index.mjs
@@ -0,0 +1,86 @@
+import { appendFile, writeFile } from 'node:fs/promises';
+import process from 'node:process';
+
+import {
+ createFilePatchManifest,
+ describeFile,
+ diffFiles,
+} from '../node/index.mjs';
+
+function input(name, options = {}) {
+ const value = process.env[`INPUT_${name.toUpperCase()}`]?.trim();
+ if (!value && options.required) {
+ const error = new Error(`missing required action input: ${name}`);
+ error.code = 'EINVAL';
+ throw error;
+ }
+ return value || options.defaultValue;
+}
+
+async function setOutputs(values) {
+ const outputPath = process.env.GITHUB_OUTPUT;
+ if (!outputPath) {
+ for (const [name, value] of Object.entries(values)) {
+ process.stdout.write(`${name}=${value}\n`);
+ }
+ return;
+ }
+ const lines = Object.entries(values)
+ .map(([name, value]) => `${name}=${String(value).replace(/\r?\n/g, ' ')}`)
+ .join('\n');
+ await appendFile(outputPath, `${lines}\n`);
+}
+
+function escapeWorkflowCommand(value) {
+ return String(value)
+ .replace(/%/g, '%25')
+ .replace(/\r/g, '%0D')
+ .replace(/\n/g, '%0A');
+}
+
+async function main() {
+ const oldPath = input('OLD-FILE', { required: true });
+ const newPath = input('NEW-FILE', { required: true });
+ const patchPath = input('PATCH-FILE', { defaultValue: 'update.patch' });
+ const manifestPath = input('MANIFEST-FILE', {
+ defaultValue: 'patch-manifest.json',
+ });
+ const releaseId = input('RELEASE-ID');
+ const maximumRatio = Number(
+ input('MAX-PATCH-RATIO', { defaultValue: '0.85' })
+ );
+ if (!Number.isFinite(maximumRatio) || maximumRatio < 0 || maximumRatio > 1) {
+ const error = new Error('max-patch-ratio must be between 0 and 1');
+ error.code = 'EINVAL';
+ throw error;
+ }
+
+ const patch = await diffFiles(oldPath, newPath, patchPath);
+ const target = await describeFile(newPath);
+ const ratio = patch.bytes / Math.max(1, target.bytes);
+ const manifest = await createFilePatchManifest(oldPath, patchPath, newPath, {
+ releaseId,
+ });
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
+ flag: 'wx',
+ });
+ await setOutputs({
+ 'strategy': ratio <= maximumRatio ? 'patch' : 'full',
+ 'patch-file': patchPath,
+ 'manifest-file': manifestPath,
+ 'patch-bytes': patch.bytes,
+ 'target-bytes': target.bytes,
+ 'patch-ratio': ratio.toFixed(6),
+ 'savings-ratio': Math.max(0, 1 - ratio).toFixed(6),
+ });
+}
+
+main().catch((error) => {
+ const code = error && error.code ? error.code : 'EACTION';
+ process.stderr.write(
+ `::error title=${escapeWorkflowCommand(code)}::${escapeWorkflowCommand(
+ error.message || error
+ )}\n`
+ );
+ process.exitCode = 1;
+});
diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt
index 3e5a881..bf04d18 100644
--- a/android/CMakeLists.txt
+++ b/android/CMakeLists.txt
@@ -7,7 +7,9 @@ set (CMAKE_CXX_STANDARD 11)
file(GLOB BZIP2_SOURCES "../cpp/bzlib/*.c")
set(SOURCES
../cpp/bsdiff.c
+ ../cpp/bsdiff40_converter.c
../cpp/bspatch.c
+ ../cpp/bspatch_streaming.c
../cpp/react-native-bs-diff-patch.cpp
${BZIP2_SOURCES}
)
diff --git a/benchmarks/README.md b/benchmarks/README.md
index bc6829f..105c10b 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -25,5 +25,5 @@ On the recorded Apple M3 Pro baseline, native completed all three large sizes.
Web completed 16 and 64 MiB, but its 128 MiB diff returned `EWEBASSEMBLY` after
reaching the current WebAssembly memory boundary. The failed sample is retained
intentionally: it is a measured limitation, not a flaky result. See the
-[large-file roadmap](../docs/large-files-v04.md) before interpreting or changing
+[large-file roadmap](../docs/large-files-roadmap.md) before interpreting or changing
these limits.
diff --git a/bin/react-native-bs-diff-patch.mjs b/bin/react-native-bs-diff-patch.mjs
new file mode 100755
index 0000000..f254f03
--- /dev/null
+++ b/bin/react-native-bs-diff-patch.mjs
@@ -0,0 +1,302 @@
+#!/usr/bin/env node
+
+import {
+ constants as fsConstants,
+ copyFile,
+ mkdir,
+ readdir,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+
+import {
+ createFilePatchManifest,
+ convertBsdiff40File,
+ describeFile,
+ diffFiles,
+ inspectPatchFile,
+ patchFiles,
+ verifyPatchFiles,
+} from '../node/index.mjs';
+import {
+ canonicalJson,
+ createPatchBundle,
+ PATCH_FORMAT,
+} from '../toolkit/index.mjs';
+
+const HELP = `Verified Delta Pipeline for react-native-bs-diff-patch
+
+Usage:
+ react-native-bs-diff-patch diff -o
+ react-native-bs-diff-patch patch -o
+ react-native-bs-diff-patch inspect [--json]
+ react-native-bs-diff-patch verify
+ react-native-bs-diff-patch manifest -o
+ react-native-bs-diff-patch convert -o
+ react-native-bs-diff-patch bundle --from --to [--out ]
+
+Bundle options:
+ --max-ratio <0..1> Use the full file when a patch exceeds this ratio (default: 0.85)
+ --release-id Add a release identifier to the generated manifest
+`;
+
+function fail(message) {
+ const error = new Error(message);
+ error.code = 'EINVAL';
+ throw error;
+}
+
+function parseArguments(argv) {
+ const [command, ...tokens] = argv;
+ const positionals = [];
+ const options = {};
+ const flags = new Set(['--json', '--help', '-h']);
+ const aliases = new Map([
+ ['-o', 'output'],
+ ['--output', 'output'],
+ ['--from', 'from'],
+ ['--to', 'to'],
+ ['--out', 'out'],
+ ['--max-ratio', 'maxRatio'],
+ ['--release-id', 'releaseId'],
+ ]);
+
+ for (let index = 0; index < tokens.length; index += 1) {
+ const token = tokens[index];
+ if (flags.has(token)) {
+ options[token.replace(/^-+/, '')] = true;
+ continue;
+ }
+ if (aliases.has(token)) {
+ const value = tokens[index + 1];
+ if (value === undefined || value.startsWith('-')) {
+ fail(`${token} requires a value`);
+ }
+ options[aliases.get(token)] = value;
+ index += 1;
+ continue;
+ }
+ if (token.startsWith('-')) {
+ fail(`unknown option: ${token}`);
+ }
+ positionals.push(token);
+ }
+ return { command, options, positionals };
+}
+
+function print(value) {
+ process.stdout.write(
+ `${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`
+ );
+}
+
+function requirePositionals(positionals, count, usage) {
+ if (positionals.length !== count) {
+ fail(`expected ${usage}`);
+ }
+}
+
+function requireOutput(options) {
+ if (!options.output) {
+ fail('missing -o ');
+ }
+ return options.output;
+}
+
+async function makeBundle(options) {
+ if (!options.from || !options.to) {
+ fail('bundle requires --from and --to ');
+ }
+ const maximumRatio =
+ options.maxRatio === undefined ? 0.85 : Number(options.maxRatio);
+ if (!Number.isFinite(maximumRatio) || maximumRatio < 0 || maximumRatio > 1) {
+ fail('--max-ratio must be between 0 and 1');
+ }
+
+ const targetPath = path.resolve(options.to);
+ const outputDirectory = path.resolve(
+ options.out ?? `${targetPath}.verified-bundle`
+ );
+ await mkdir(path.dirname(outputDirectory), { recursive: true });
+ try {
+ await mkdir(outputDirectory);
+ } catch (error) {
+ if (error && error.code === 'EEXIST') {
+ const wrapped = new Error(
+ `bundle output directory already exists: ${outputDirectory}`
+ );
+ wrapped.code = 'EDESTEXISTS';
+ throw wrapped;
+ }
+ throw error;
+ }
+
+ try {
+ const targetName = `full-${path.basename(targetPath)}`;
+ const bundledTargetPath = path.join(outputDirectory, targetName);
+ await copyFile(targetPath, bundledTargetPath, fsConstants.COPYFILE_EXCL);
+ const target = await describeFile(bundledTargetPath, {
+ name: targetName,
+ url: targetName,
+ });
+ const baselineEntries = (
+ await readdir(options.from, { withFileTypes: true })
+ )
+ .filter((entry) => entry.isFile())
+ .sort((left, right) => left.name.localeCompare(right.name));
+ if (baselineEntries.length === 0) {
+ fail(`no baseline files found in ${options.from}`);
+ }
+
+ const patches = [];
+ const decisions = [];
+ for (let index = 0; index < baselineEntries.length; index += 1) {
+ const entry = baselineEntries[index];
+ const baselinePath = path.join(options.from, entry.name);
+ const patchName = `${String(index + 1).padStart(3, '0')}-${
+ entry.name
+ }.patch`;
+ const patchPath = path.join(outputDirectory, patchName);
+ const result = await diffFiles(baselinePath, targetPath, patchPath);
+ const baseline = await describeFile(baselinePath, { name: entry.name });
+ const ratio = result.bytes / Math.max(1, target.bytes);
+
+ if (ratio <= maximumRatio) {
+ patches.push({
+ format: PATCH_FORMAT,
+ baseline,
+ patch: {
+ bytes: result.bytes,
+ name: patchName,
+ sha256: result.sha256,
+ url: patchName,
+ },
+ declaredTargetBytes: String(target.bytes),
+ });
+ decisions.push({
+ baseline: entry.name,
+ patchBytes: result.bytes,
+ ratio,
+ strategy: 'patch',
+ });
+ } else {
+ await rm(patchPath);
+ decisions.push({
+ baseline: entry.name,
+ patchBytes: result.bytes,
+ ratio,
+ reason: 'PATCH_RATIO_EXCEEDED',
+ strategy: 'full',
+ });
+ }
+ }
+
+ const bundle = createPatchBundle({
+ full: target,
+ patches,
+ releaseId: options.releaseId,
+ target,
+ });
+ const manifestPath = path.join(outputDirectory, 'bundle-manifest.json');
+ await writeFile(manifestPath, `${JSON.stringify(bundle, null, 2)}\n`, {
+ flag: 'wx',
+ });
+ await writeFile(
+ path.join(outputDirectory, 'bundle-manifest.canonical.json'),
+ canonicalJson(bundle),
+ { flag: 'wx' }
+ );
+ return {
+ decisions,
+ manifest: manifestPath,
+ outputDirectory,
+ patchCount: patches.length,
+ targetBytes: target.bytes,
+ };
+ } catch (error) {
+ await rm(outputDirectory, { force: true, recursive: true });
+ throw error;
+ }
+}
+
+async function main() {
+ const { command, options, positionals } = parseArguments(
+ process.argv.slice(2)
+ );
+ if (!command || command === 'help' || options.help || options.h) {
+ print(HELP.trimEnd());
+ return;
+ }
+
+ if (command === 'diff') {
+ requirePositionals(positionals, 2, 'diff ');
+ print(
+ await diffFiles(positionals[0], positionals[1], requireOutput(options))
+ );
+ return;
+ }
+ if (command === 'patch') {
+ requirePositionals(positionals, 2, 'patch ');
+ print(
+ await patchFiles(positionals[0], positionals[1], requireOutput(options))
+ );
+ return;
+ }
+ if (command === 'inspect') {
+ requirePositionals(positionals, 1, 'inspect ');
+ const result = await inspectPatchFile(positionals[0]);
+ print(
+ options.json
+ ? result
+ : [
+ `format: ${result.format}`,
+ `valid: ${result.valid}`,
+ `patch bytes: ${result.patchBytes}`,
+ `target bytes: ${result.declaredTargetBytes ?? 'unknown'}`,
+ ...(result.issue ? [`issue: ${result.issue}`] : []),
+ ].join('\n')
+ );
+ return;
+ }
+ if (command === 'verify') {
+ requirePositionals(positionals, 3, 'verify ');
+ const result = await verifyPatchFiles(...positionals);
+ print(result);
+ if (!result.verified) {
+ process.exitCode = 1;
+ }
+ return;
+ }
+ if (command === 'manifest') {
+ requirePositionals(positionals, 3, 'manifest ');
+ const manifest = await createFilePatchManifest(...positionals, {
+ releaseId: options.releaseId,
+ });
+ await writeFile(
+ requireOutput(options),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ { flag: 'wx' }
+ );
+ print(manifest);
+ return;
+ }
+ if (command === 'convert') {
+ requirePositionals(positionals, 1, 'convert ');
+ print(await convertBsdiff40File(positionals[0], requireOutput(options)));
+ return;
+ }
+ if (command === 'bundle') {
+ requirePositionals(positionals, 0, 'bundle options only');
+ print(await makeBundle(options));
+ return;
+ }
+ fail(`unknown command: ${command}`);
+}
+
+main().catch((error) => {
+ const code = error && error.code ? error.code : 'ECLI';
+ process.stderr.write(`[${code}] ${error.message || String(error)}\n`);
+ process.exitCode = 1;
+});
diff --git a/cpp/bsdiff.c b/cpp/bsdiff.c
index 471703f..ef2dc18 100644
--- a/cpp/bsdiff.c
+++ b/cpp/bsdiff.c
@@ -454,7 +454,7 @@ static int input_limit_result(
const struct bs_operation_options *options,
int64_t size)
{
- if (options != NULL && options->max_input_bytes > 0 &&
+ if (bs_operation_has_input_limit(options) &&
size > options->max_input_bytes)
return BS_OPERATION_INPUT_TOO_LARGE;
return BS_OPERATION_OK;
@@ -520,7 +520,7 @@ static int bz2_write(struct bsdiff_stream *stream, const void *buffer, int size)
return -1;
}
- if (context->options != NULL && context->options->max_output_bytes > 0) {
+ if (bs_operation_has_output_limit(context->options)) {
long position = ftell(context->file);
if (position >= 0 && position > context->options->max_output_bytes) {
context->result = BS_OPERATION_OUTPUT_TOO_LARGE;
@@ -570,6 +570,11 @@ static int bsDiffFileInternal(
result = BS_OPERATION_CANCELLED;
goto cleanup;
}
+ if (bs_operation_has_output_limit(options) &&
+ options->max_output_bytes < 24) {
+ result = BS_OPERATION_OUTPUT_TOO_LARGE;
+ goto cleanup;
+ }
operation_progress(options, BS_OPERATION_READING, 0.0);
errorStage = "read-old";
@@ -620,12 +625,6 @@ static int bsDiffFileInternal(
goto cleanup;
operation_progress(options, BS_OPERATION_READING, 0.15);
- if (options != NULL && options->max_output_bytes > 0 &&
- options->max_output_bytes < 24) {
- result = BS_OPERATION_OUTPUT_TOO_LARGE;
- goto cleanup;
- }
-
errorStage = "open-output";
fd = outputFd >= 0 ? outputFd : open(patchFile, O_CREAT|O_EXCL|O_WRONLY, 0666);
outputFd = -1;
@@ -665,7 +664,7 @@ static int bsDiffFileInternal(
context.bz2 = NULL;
if (bz2err != BZ_OK)
goto cleanup;
- if (options != NULL && options->max_output_bytes > 0) {
+ if (bs_operation_has_output_limit(options)) {
long position = ftell(pf);
if (position < 0 || position > options->max_output_bytes) {
result = BS_OPERATION_OUTPUT_TOO_LARGE;
diff --git a/cpp/bsdiff40_converter.c b/cpp/bsdiff40_converter.c
new file mode 100644
index 0000000..19985ed
--- /dev/null
+++ b/cpp/bsdiff40_converter.c
@@ -0,0 +1,257 @@
+#include "bsdiff40_converter.h"
+
+#include "bzlib/bzlib.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define CONVERTER_CHUNK (64 * 1024)
+
+static int64_t decode_offset(const uint8_t *buffer)
+{
+ int64_t value = buffer[7] & 0x7f;
+ int index;
+ for (index = 6; index >= 0; index--)
+ value = value * 256 + buffer[index];
+ return (buffer[7] & 0x80) != 0 ? -value : value;
+}
+
+static void encode_offset(int64_t value, uint8_t *buffer)
+{
+ int64_t magnitude = value < 0 ? -value : value;
+ int index;
+ for (index = 0; index < 8; index++) {
+ buffer[index] = (uint8_t)(magnitude & 0xff);
+ magnitude >>= 8;
+ }
+ if (value < 0)
+ buffer[7] |= 0x80;
+}
+
+static int checked_add(int64_t left, int64_t right, int64_t *result)
+{
+ if ((right > 0 && left > INT64_MAX - right) ||
+ (right < 0 && left < INT64_MIN - right))
+ return -1;
+ *result = left + right;
+ return 0;
+}
+
+static int read_exact(BZFILE *stream, void *buffer, int length)
+{
+ int offset = 0;
+ while (offset < length) {
+ int error;
+ int count = BZ2_bzRead(
+ &error,
+ stream,
+ (uint8_t *)buffer + offset,
+ length - offset);
+ if (count <= 0 || (error != BZ_OK && error != BZ_STREAM_END))
+ return -1;
+ offset += count;
+ }
+ return 0;
+}
+
+static int write_exact(BZFILE *stream, const void *buffer, int length)
+{
+ int error;
+ BZ2_bzWrite(&error, stream, (void *)buffer, length);
+ return error == BZ_OK ? 0 : -1;
+}
+
+static int copy_bytes(BZFILE *input, BZFILE *output, int64_t length)
+{
+ uint8_t buffer[CONVERTER_CHUNK];
+ int64_t offset = 0;
+ while (offset < length) {
+ int chunk = length - offset > CONVERTER_CHUNK
+ ? CONVERTER_CHUNK
+ : (int)(length - offset);
+ if (read_exact(input, buffer, chunk) != 0 ||
+ write_exact(output, buffer, chunk) != 0)
+ return -1;
+ offset += chunk;
+ }
+ return 0;
+}
+
+static FILE *open_at(const char *path, int64_t offset)
+{
+ FILE *file = fopen(path, "rb");
+ if (file == NULL)
+ return NULL;
+ if (fseeko(file, (off_t)offset, SEEK_SET) != 0) {
+ fclose(file);
+ return NULL;
+ }
+ return file;
+}
+
+int bsConvertBsdiff40File(
+ const char *legacy_patch_file,
+ const char *patch_file)
+{
+ FILE *header_file = NULL;
+ FILE *control_file = NULL;
+ FILE *diff_file = NULL;
+ FILE *extra_file = NULL;
+ FILE *output_file = NULL;
+ BZFILE *control_stream = NULL;
+ BZFILE *diff_stream = NULL;
+ BZFILE *extra_stream = NULL;
+ BZFILE *output_stream = NULL;
+ int output_fd = -1;
+ int bz_error = BZ_OK;
+ int result = -1;
+ int output_created = 0;
+ struct stat patch_stat;
+ uint8_t header[32];
+ uint8_t target_size_buffer[8];
+ uint8_t control_buffer[24];
+ int64_t control_length;
+ int64_t diff_length;
+ int64_t target_size;
+ int64_t target_position = 0;
+ int64_t old_position = 0;
+ int64_t block_offset;
+
+ if (legacy_patch_file == NULL || patch_file == NULL)
+ goto cleanup;
+ header_file = fopen(legacy_patch_file, "rb");
+ if (header_file == NULL ||
+ fstat(fileno(header_file), &patch_stat) != 0 ||
+ fread(header, 1, sizeof(header), header_file) != sizeof(header) ||
+ memcmp(header, "BSDIFF40", 8) != 0)
+ goto cleanup;
+ control_length = decode_offset(header + 8);
+ diff_length = decode_offset(header + 16);
+ target_size = decode_offset(header + 24);
+ if (control_length <= 0 || diff_length <= 0 || target_size < 0 ||
+ checked_add(32, control_length, &block_offset) != 0 ||
+ checked_add(block_offset, diff_length, &block_offset) != 0 ||
+ block_offset >= patch_stat.st_size)
+ goto cleanup;
+ fclose(header_file);
+ header_file = NULL;
+
+ control_file = open_at(legacy_patch_file, 32);
+ diff_file = open_at(legacy_patch_file, 32 + control_length);
+ extra_file = open_at(
+ legacy_patch_file,
+ 32 + control_length + diff_length);
+ if (control_file == NULL || diff_file == NULL || extra_file == NULL)
+ goto cleanup;
+ control_stream = BZ2_bzReadOpen(
+ &bz_error,
+ control_file,
+ 0,
+ 1,
+ NULL,
+ 0);
+ if (control_stream == NULL || bz_error != BZ_OK)
+ goto cleanup;
+ diff_stream = BZ2_bzReadOpen(
+ &bz_error,
+ diff_file,
+ 0,
+ 1,
+ NULL,
+ 0);
+ if (diff_stream == NULL || bz_error != BZ_OK)
+ goto cleanup;
+ extra_stream = BZ2_bzReadOpen(
+ &bz_error,
+ extra_file,
+ 0,
+ 1,
+ NULL,
+ 0);
+ if (extra_stream == NULL || bz_error != BZ_OK)
+ goto cleanup;
+
+ output_fd = open(patch_file, O_CREAT | O_EXCL | O_WRONLY, 0666);
+ if (output_fd < 0)
+ goto cleanup;
+ output_created = 1;
+ output_file = fdopen(output_fd, "wb");
+ if (output_file == NULL)
+ goto cleanup;
+ output_fd = -1;
+ encode_offset(target_size, target_size_buffer);
+ if (fwrite("ENDSLEY/BSDIFF43", 16, 1, output_file) != 1 ||
+ fwrite(target_size_buffer, sizeof(target_size_buffer), 1, output_file) != 1)
+ goto cleanup;
+ output_stream = BZ2_bzWriteOpen(&bz_error, output_file, 9, 0, 0);
+ if (output_stream == NULL || bz_error != BZ_OK)
+ goto cleanup;
+
+ while (target_position < target_size) {
+ int64_t control[3];
+ int index;
+ int64_t next_old_position;
+ if (read_exact(
+ control_stream,
+ control_buffer,
+ sizeof(control_buffer)) != 0)
+ goto cleanup;
+ for (index = 0; index < 3; index++)
+ control[index] = decode_offset(control_buffer + index * 8);
+ if (control[0] < 0 || control[1] < 0 ||
+ control[0] > target_size - target_position ||
+ checked_add(target_position, control[0], &target_position) != 0 ||
+ control[1] > target_size - target_position ||
+ checked_add(target_position, control[1], &target_position) != 0 ||
+ checked_add(old_position, control[0], &next_old_position) != 0 ||
+ checked_add(next_old_position, control[2], &old_position) != 0)
+ goto cleanup;
+ if (write_exact(
+ output_stream,
+ control_buffer,
+ sizeof(control_buffer)) != 0 ||
+ copy_bytes(diff_stream, output_stream, control[0]) != 0 ||
+ copy_bytes(extra_stream, output_stream, control[1]) != 0)
+ goto cleanup;
+ }
+
+ BZ2_bzWriteClose(&bz_error, output_stream, 0, NULL, NULL);
+ output_stream = NULL;
+ if (bz_error != BZ_OK || fflush(output_file) != 0 ||
+ fsync(fileno(output_file)) != 0 || fclose(output_file) != 0)
+ goto cleanup;
+ output_file = NULL;
+ result = 0;
+
+cleanup:
+ if (output_stream != NULL)
+ BZ2_bzWriteClose(&bz_error, output_stream, 1, NULL, NULL);
+ if (control_stream != NULL)
+ BZ2_bzReadClose(&bz_error, control_stream);
+ if (diff_stream != NULL)
+ BZ2_bzReadClose(&bz_error, diff_stream);
+ if (extra_stream != NULL)
+ BZ2_bzReadClose(&bz_error, extra_stream);
+ if (header_file != NULL)
+ fclose(header_file);
+ if (control_file != NULL)
+ fclose(control_file);
+ if (diff_file != NULL)
+ fclose(diff_file);
+ if (extra_file != NULL)
+ fclose(extra_file);
+ if (output_file != NULL)
+ fclose(output_file);
+ if (output_fd >= 0)
+ close(output_fd);
+ if (result != 0 && output_created)
+ unlink(patch_file);
+ return result;
+}
diff --git a/cpp/bsdiff40_converter.h b/cpp/bsdiff40_converter.h
new file mode 100644
index 0000000..5197daf
--- /dev/null
+++ b/cpp/bsdiff40_converter.h
@@ -0,0 +1,16 @@
+#ifndef BSDIFF40_CONVERTER_H
+#define BSDIFF40_CONVERTER_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int bsConvertBsdiff40File(
+ const char *legacy_patch_file,
+ const char *patch_file);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/cpp/bsdiffpatch_operation.h b/cpp/bsdiffpatch_operation.h
index 1906353..39358b7 100644
--- a/cpp/bsdiffpatch_operation.h
+++ b/cpp/bsdiffpatch_operation.h
@@ -2,6 +2,7 @@
#define BSDIFFPATCH_OPERATION_H
#include
+#include
#ifdef __cplusplus
extern "C" {
@@ -13,7 +14,8 @@ enum bs_operation_result {
BS_OPERATION_INPUT_TOO_LARGE = -2,
BS_OPERATION_OUTPUT_TOO_LARGE = -3,
BS_OPERATION_CANCELLED = -4,
- BS_OPERATION_DESTINATION_EXISTS = -5
+ BS_OPERATION_DESTINATION_EXISTS = -5,
+ BS_OPERATION_INVALID_ARGUMENT = -6
};
enum bs_operation_phase {
@@ -22,14 +24,40 @@ enum bs_operation_phase {
BS_OPERATION_WRITING = 2
};
+/*
+ * A positive limit historically enabled the corresponding guard. The flags
+ * preserve that behaviour for zero-initialized callers rebuilt with this
+ * header while allowing the Web bridge to express a deliberate zero-byte
+ * budget (where zero must not mean "unlimited").
+ */
+enum bs_operation_limit_flags {
+ BS_OPERATION_LIMIT_INPUT = 1 << 0,
+ BS_OPERATION_LIMIT_OUTPUT = 1 << 1
+};
+
struct bs_operation_options {
int64_t max_input_bytes;
int64_t max_output_bytes;
void *opaque;
int (*is_cancelled)(void *opaque);
void (*progress)(void *opaque, int phase, double progress);
+ unsigned int limit_flags;
};
+static inline int bs_operation_has_input_limit(
+ const struct bs_operation_options *options)
+{
+ return options != NULL && (options->max_input_bytes > 0 ||
+ (options->limit_flags & BS_OPERATION_LIMIT_INPUT) != 0);
+}
+
+static inline int bs_operation_has_output_limit(
+ const struct bs_operation_options *options)
+{
+ return options != NULL && (options->max_output_bytes > 0 ||
+ (options->limit_flags & BS_OPERATION_LIMIT_OUTPUT) != 0);
+}
+
int bsDiffFileWithOptions(
const char *old_file,
const char *new_file,
@@ -42,6 +70,12 @@ int bsPatchFileWithOptions(
const char *patch_file,
const struct bs_operation_options *options);
+int bsPatchFileStreamingWithOptions(
+ const char *old_file,
+ const char *new_file,
+ const char *patch_file,
+ const struct bs_operation_options *options);
+
#ifdef __cplusplus
}
#endif
diff --git a/cpp/bspatch.c b/cpp/bspatch.c
index b6b6764..9bacdd5 100644
--- a/cpp/bspatch.c
+++ b/cpp/bspatch.c
@@ -31,6 +31,7 @@
#include
#include "bspatch.h"
+#include "bspatch_streaming.h"
#include "bsdiffpatch_operation.h"
#include
@@ -42,8 +43,6 @@
#include
#endif
-#define BSPATCH_IO_CHUNK (64 * 1024)
-
static int bspatch_cancelled(const struct bspatch_stream *stream)
{
return stream->is_cancelled != NULL && stream->is_cancelled(stream);
@@ -165,254 +164,6 @@ static void operation_progress(
options->progress(options->opaque, phase, progress);
}
-static int input_limit_result(
- const struct bs_operation_options *options,
- int64_t size)
-{
- if (options != NULL && options->max_input_bytes > 0 &&
- size > options->max_input_bytes)
- return BS_OPERATION_INPUT_TOO_LARGE;
- return BS_OPERATION_OK;
-}
-
-struct bspatch_file_stream_context {
- BZFILE *bz2;
- const struct bs_operation_options *options;
- int result;
-};
-
-static int bspatch_file_cancelled(const struct bspatch_stream *stream)
-{
- struct bspatch_file_stream_context *context = stream->opaque;
- return operation_cancelled(context->options);
-}
-
-static void bspatch_file_progress(const struct bspatch_stream *stream, double progress)
-{
- struct bspatch_file_stream_context *context = stream->opaque;
- operation_progress(
- context->options,
- BS_OPERATION_PROCESSING,
- 0.15 + progress * 0.70);
-}
-
-static int bz2_read(const struct bspatch_stream *stream, void *buffer, int length)
-{
- struct bspatch_file_stream_context *context = stream->opaque;
- int offset = 0;
-
- while (offset < length) {
- int bz2err;
- int chunk = length - offset > BSPATCH_IO_CHUNK
- ? BSPATCH_IO_CHUNK
- : length - offset;
- int count;
-
- if (operation_cancelled(context->options)) {
- context->result = BS_OPERATION_CANCELLED;
- return -1;
- }
- count = BZ2_bzRead(&bz2err, context->bz2, (uint8_t *)buffer + offset, chunk);
- if (count != chunk) {
- context->result = BS_OPERATION_ERROR;
- return -1;
- }
- offset += count;
- }
- return 0;
-}
-
-static off_t readFileToBuffer(
- int fd,
- uint8_t *buffer,
- off_t bufferSize,
- const struct bs_operation_options *options)
-{
- off_t bytesRead = 0;
- while (bytesRead < bufferSize) {
- size_t remaining = (size_t)(bufferSize - bytesRead);
- size_t chunk = remaining > BSPATCH_IO_CHUNK ? BSPATCH_IO_CHUNK : remaining;
- ssize_t count;
-
- if (operation_cancelled(options))
- break;
- count = read(fd, buffer + bytesRead, chunk);
- if (count <= 0)
- break;
- bytesRead += count;
- }
- return bytesRead;
-}
-
-static off_t writeFileFromBuffer(
- int fd,
- uint8_t *buffer,
- off_t bufferSize,
- const struct bs_operation_options *options)
-{
- off_t bytesWritten = 0;
- while (bytesWritten < bufferSize) {
- size_t remaining = (size_t)(bufferSize - bytesWritten);
- size_t chunk = remaining > BSPATCH_IO_CHUNK ? BSPATCH_IO_CHUNK : remaining;
- ssize_t count;
-
- if (operation_cancelled(options))
- break;
- count = write(fd, buffer + bytesWritten, chunk);
- if (count <= 0)
- break;
- bytesWritten += count;
- operation_progress(
- options,
- BS_OPERATION_WRITING,
- bufferSize > 0
- ? 0.85 + 0.15 * ((double)bytesWritten / (double)bufferSize)
- : 1.0);
- }
- return bytesWritten;
-}
-
-static int bsPatchFileInternal(
- const char *oldFile,
- const char *newFile,
- const char *patchFile,
- int outputFd,
- const struct bs_operation_options *options)
-{
- FILE *f = NULL;
- int fd = -1;
- int bz2err;
- int closeResult;
- int result = BS_OPERATION_ERROR;
- int outputCreated = outputFd >= 0;
- uint8_t header[24];
- uint8_t *old = NULL, *new = NULL;
- int64_t oldsize = 0, newsize = 0;
- off_t measuredSize;
- BZFILE *bz2 = NULL;
- struct bspatch_stream stream;
- struct bspatch_file_stream_context context;
- struct stat patchStat;
-
- memset(&stream, 0, sizeof(stream));
- memset(&context, 0, sizeof(context));
- stream.read = bz2_read;
- stream.is_cancelled = bspatch_file_cancelled;
- stream.progress = bspatch_file_progress;
- stream.opaque = &context;
- context.options = options;
- context.result = BS_OPERATION_ERROR;
-
- if (oldFile == NULL || newFile == NULL || patchFile == NULL)
- goto cleanup;
- if (operation_cancelled(options)) {
- result = BS_OPERATION_CANCELLED;
- goto cleanup;
- }
-
- operation_progress(options, BS_OPERATION_READING, 0.0);
- f = fopen(patchFile, "rb");
- if (f == NULL)
- goto cleanup;
- if (fstat(fileno(f), &patchStat) != 0 || patchStat.st_size < 0)
- goto cleanup;
- if (input_limit_result(options, (int64_t)patchStat.st_size) != BS_OPERATION_OK) {
- result = BS_OPERATION_INPUT_TOO_LARGE;
- goto cleanup;
- }
- if (fread(header, 1, 24, f) != 24)
- goto cleanup;
- if (memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0)
- goto cleanup;
- newsize = offtin(header + 16);
- if (newsize < 0 || (uint64_t)newsize > SIZE_MAX - 1)
- goto cleanup;
- if (options != NULL && options->max_output_bytes > 0 &&
- newsize > options->max_output_bytes) {
- result = BS_OPERATION_OUTPUT_TOO_LARGE;
- goto cleanup;
- }
- operation_progress(options, BS_OPERATION_READING, 0.05);
-
- fd = open(oldFile, O_RDONLY, 0);
- if (fd < 0)
- goto cleanup;
- measuredSize = lseek(fd, 0, SEEK_END);
- if (measuredSize < 0 || (uint64_t)measuredSize > SIZE_MAX - 1)
- goto cleanup;
- oldsize = (int64_t)measuredSize;
- if (input_limit_result(options, oldsize) != BS_OPERATION_OK) {
- result = BS_OPERATION_INPUT_TOO_LARGE;
- goto cleanup;
- }
- old = malloc((size_t)oldsize + 1);
- if (old == NULL || lseek(fd, 0, SEEK_SET) != 0 ||
- readFileToBuffer(fd, old, (off_t)oldsize, options) != (off_t)oldsize) {
- if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED;
- goto cleanup;
- }
- closeResult = close(fd);
- fd = -1;
- if (closeResult != 0)
- goto cleanup;
- operation_progress(options, BS_OPERATION_READING, 0.15);
-
- new = malloc((size_t)newsize + 1);
- if (new == NULL)
- goto cleanup;
- bz2 = BZ2_bzReadOpen(&bz2err, f, 0, 1, NULL, 0);
- if (bz2 == NULL || bz2err != BZ_OK)
- goto cleanup;
- context.bz2 = bz2;
- if (bspatch(old, oldsize, new, newsize, &stream)) {
- result = context.result;
- if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED;
- goto cleanup;
- }
-
- BZ2_bzReadClose(&bz2err, bz2);
- bz2 = NULL;
- context.bz2 = NULL;
- closeResult = fclose(f);
- f = NULL;
- if (closeResult != 0)
- goto cleanup;
-
- fd = outputFd >= 0 ? outputFd : open(newFile, O_CREAT|O_EXCL|O_WRONLY, 0666);
- outputFd = -1;
- if (fd < 0) {
- if (errno == EEXIST) result = BS_OPERATION_DESTINATION_EXISTS;
- goto cleanup;
- }
- outputCreated = 1;
- if (writeFileFromBuffer(fd, new, (off_t)newsize, options) != (off_t)newsize) {
- if (operation_cancelled(options)) result = BS_OPERATION_CANCELLED;
- goto cleanup;
- }
- if (options != NULL && fsync(fd) != 0)
- goto cleanup;
- closeResult = close(fd);
- fd = -1;
- if (closeResult != 0)
- goto cleanup;
- result = BS_OPERATION_OK;
-
-cleanup:
- if (bz2 != NULL)
- BZ2_bzReadClose(&bz2err, bz2);
- if (f != NULL)
- fclose(f);
- if (fd >= 0)
- close(fd);
- if (outputFd >= 0)
- close(outputFd);
- if (result != BS_OPERATION_OK && outputCreated && newFile != NULL)
- unlink(newFile);
- free(new);
- free(old);
- return result;
-}
-
static int create_sibling_temp(const char *destination, char **temporaryPath)
{
size_t length;
@@ -503,10 +254,14 @@ static int commit_sibling_temp(const char *temporaryPath, const char *destinatio
int bsPatchFile(const char *oldFile, const char *newFile, const char *patchFile)
{
- return bsPatchFileInternal(oldFile, newFile, patchFile, -1, NULL);
+ return bsPatchFileStreamingWithOptions(
+ oldFile,
+ newFile,
+ patchFile,
+ NULL);
}
-int bsPatchFileWithOptions(
+int bsPatchFileStreamingWithOptions(
const char *oldFile,
const char *newFile,
const char *patchFile,
@@ -526,7 +281,11 @@ int bsPatchFileWithOptions(
temporaryFd = create_sibling_temp(newFile, &temporaryPath);
if (temporaryFd < 0)
return BS_OPERATION_ERROR;
- result = bsPatchFileInternal(oldFile, temporaryPath, patchFile, temporaryFd, options);
+ result = bsPatchFileStreamingToFd(
+ oldFile,
+ patchFile,
+ temporaryFd,
+ options);
if (result == BS_OPERATION_OK) {
if (operation_cancelled(options)) {
result = BS_OPERATION_CANCELLED;
@@ -541,3 +300,16 @@ int bsPatchFileWithOptions(
free(temporaryPath);
return result;
}
+
+int bsPatchFileWithOptions(
+ const char *oldFile,
+ const char *newFile,
+ const char *patchFile,
+ const struct bs_operation_options *options)
+{
+ return bsPatchFileStreamingWithOptions(
+ oldFile,
+ newFile,
+ patchFile,
+ options);
+}
diff --git a/cpp/bspatch_streaming.c b/cpp/bspatch_streaming.c
new file mode 100644
index 0000000..99eb200
--- /dev/null
+++ b/cpp/bspatch_streaming.c
@@ -0,0 +1,348 @@
+#include "bspatch_streaming.h"
+
+#include "bzlib/bzlib.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define STREAM_CHUNK (64 * 1024)
+
+static int is_cancelled(const struct bs_operation_options *options)
+{
+ return options != NULL && options->is_cancelled != NULL &&
+ options->is_cancelled(options->opaque);
+}
+
+static void report_progress(
+ const struct bs_operation_options *options,
+ int phase,
+ double progress)
+{
+ if (options != NULL && options->progress != NULL)
+ options->progress(options->opaque, phase, progress);
+}
+
+static int64_t decode_offset(const uint8_t *buffer)
+{
+ int64_t value = buffer[7] & 0x7f;
+ int index;
+ for (index = 6; index >= 0; index--)
+ value = value * 256 + buffer[index];
+ return (buffer[7] & 0x80) != 0 ? -value : value;
+}
+
+static int checked_add(int64_t left, int64_t right, int64_t *result)
+{
+ if ((right > 0 && left > INT64_MAX - right) ||
+ (right < 0 && left < INT64_MIN - right))
+ return -1;
+ *result = left + right;
+ return 0;
+}
+
+static int read_bzip_exact(
+ BZFILE *stream,
+ void *buffer,
+ size_t length,
+ const struct bs_operation_options *options)
+{
+ size_t offset = 0;
+ while (offset < length) {
+ int error;
+ int chunk = length - offset > INT_MAX
+ ? INT_MAX
+ : (int)(length - offset);
+ int count;
+ if (is_cancelled(options))
+ return BS_OPERATION_CANCELLED;
+ count = BZ2_bzRead(
+ &error,
+ stream,
+ (uint8_t *)buffer + offset,
+ chunk);
+ if (count != chunk || (error != BZ_OK && error != BZ_STREAM_END))
+ return BS_OPERATION_ERROR;
+ offset += (size_t)count;
+ }
+ return BS_OPERATION_OK;
+}
+
+static int read_old_exact(int fd, void *buffer, size_t length)
+{
+ size_t offset = 0;
+ while (offset < length) {
+ ssize_t count = read(fd, (uint8_t *)buffer + offset, length - offset);
+ if (count <= 0)
+ return BS_OPERATION_ERROR;
+ offset += (size_t)count;
+ }
+ return BS_OPERATION_OK;
+}
+
+static int write_exact(
+ int fd,
+ const void *buffer,
+ size_t length,
+ const struct bs_operation_options *options)
+{
+ size_t offset = 0;
+ while (offset < length) {
+ ssize_t count;
+ if (is_cancelled(options))
+ return BS_OPERATION_CANCELLED;
+ count = write(fd, (const uint8_t *)buffer + offset, length - offset);
+ if (count <= 0)
+ return BS_OPERATION_ERROR;
+ offset += (size_t)count;
+ }
+ return BS_OPERATION_OK;
+}
+
+static int add_old_bytes(
+ int old_fd,
+ int64_t old_size,
+ int64_t old_position,
+ uint8_t *diff,
+ uint8_t *old,
+ size_t length)
+{
+ int64_t block_end;
+ int64_t overlap_start;
+ int64_t overlap_end;
+ size_t overlap_offset;
+ size_t overlap_length;
+ size_t index;
+
+ if (checked_add(old_position, (int64_t)length, &block_end) != 0)
+ return BS_OPERATION_ERROR;
+ memset(old, 0, length);
+ overlap_start = old_position < 0 ? 0 : old_position;
+ overlap_end = block_end > old_size ? old_size : block_end;
+ if (overlap_start >= overlap_end)
+ return BS_OPERATION_OK;
+ overlap_offset = (size_t)(overlap_start - old_position);
+ overlap_length = (size_t)(overlap_end - overlap_start);
+ if (lseek(old_fd, (off_t)overlap_start, SEEK_SET) < 0)
+ return BS_OPERATION_ERROR;
+ if (read_old_exact(old_fd, old + overlap_offset, overlap_length) !=
+ BS_OPERATION_OK)
+ return BS_OPERATION_ERROR;
+ for (index = 0; index < length; index++)
+ diff[index] = (uint8_t)(diff[index] + old[index]);
+ return BS_OPERATION_OK;
+}
+
+int bsPatchFileStreamingToFd(
+ const char *old_file,
+ const char *patch_file,
+ int output_fd,
+ const struct bs_operation_options *options)
+{
+ FILE *patch = NULL;
+ BZFILE *compressed = NULL;
+ int old_fd = -1;
+ int bz_error = BZ_OK;
+ int result = BS_OPERATION_ERROR;
+ struct stat file_stat;
+ uint8_t header[24];
+ uint8_t control_buffer[8];
+ uint8_t *diff_buffer = NULL;
+ uint8_t *old_buffer = NULL;
+ int64_t control[3];
+ int64_t old_size;
+ int64_t new_size;
+ int64_t old_position = 0;
+ int64_t new_position = 0;
+
+ if (old_file == NULL || patch_file == NULL || output_fd < 0)
+ goto cleanup;
+ if (is_cancelled(options)) {
+ result = BS_OPERATION_CANCELLED;
+ goto cleanup;
+ }
+
+ report_progress(options, BS_OPERATION_READING, 0.0);
+ patch = fopen(patch_file, "rb");
+ if (patch == NULL || fstat(fileno(patch), &file_stat) != 0 ||
+ file_stat.st_size < 24)
+ goto cleanup;
+ if (bs_operation_has_input_limit(options) &&
+ file_stat.st_size > options->max_input_bytes) {
+ result = BS_OPERATION_INPUT_TOO_LARGE;
+ goto cleanup;
+ }
+ if (fread(header, 1, sizeof(header), patch) != sizeof(header) ||
+ memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0)
+ goto cleanup;
+ new_size = decode_offset(header + 16);
+ if (new_size < 0) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+ if (bs_operation_has_output_limit(options) &&
+ new_size > options->max_output_bytes) {
+ result = BS_OPERATION_OUTPUT_TOO_LARGE;
+ goto cleanup;
+ }
+ report_progress(options, BS_OPERATION_READING, 0.05);
+
+ old_fd = open(old_file, O_RDONLY);
+ if (old_fd < 0 || fstat(old_fd, &file_stat) != 0 ||
+ file_stat.st_size < 0)
+ goto cleanup;
+ old_size = (int64_t)file_stat.st_size;
+ if (bs_operation_has_input_limit(options) &&
+ old_size > options->max_input_bytes) {
+ result = BS_OPERATION_INPUT_TOO_LARGE;
+ goto cleanup;
+ }
+ report_progress(options, BS_OPERATION_READING, 0.15);
+
+ diff_buffer = malloc(STREAM_CHUNK);
+ old_buffer = malloc(STREAM_CHUNK);
+ if (diff_buffer == NULL || old_buffer == NULL)
+ goto cleanup;
+
+ compressed = BZ2_bzReadOpen(&bz_error, patch, 0, 1, NULL, 0);
+ if (compressed == NULL || bz_error != BZ_OK)
+ goto cleanup;
+
+ while (new_position < new_size) {
+ int index;
+ int64_t next_old_position;
+ int64_t processed;
+
+ if (is_cancelled(options)) {
+ result = BS_OPERATION_CANCELLED;
+ goto cleanup;
+ }
+ for (index = 0; index < 3; index++) {
+ result = read_bzip_exact(
+ compressed,
+ control_buffer,
+ sizeof(control_buffer),
+ options);
+ if (result != BS_OPERATION_OK)
+ goto cleanup;
+ control[index] = decode_offset(control_buffer);
+ }
+ if (control[0] < 0 || control[1] < 0 ||
+ control[0] > new_size - new_position ||
+ checked_add(old_position, control[0], &next_old_position) != 0) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+ if (control[0] == 0 && control[1] == 0) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+
+ processed = 0;
+ while (processed < control[0]) {
+ size_t chunk = (size_t)(control[0] - processed);
+ int64_t block_old_position;
+ if (chunk > STREAM_CHUNK)
+ chunk = STREAM_CHUNK;
+ result = read_bzip_exact(
+ compressed,
+ diff_buffer,
+ chunk,
+ options);
+ if (result != BS_OPERATION_OK)
+ goto cleanup;
+ if (checked_add(
+ old_position,
+ processed,
+ &block_old_position) != 0 ||
+ add_old_bytes(
+ old_fd,
+ old_size,
+ block_old_position,
+ diff_buffer,
+ old_buffer,
+ chunk) != BS_OPERATION_OK) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+ result = write_exact(
+ output_fd,
+ diff_buffer,
+ chunk,
+ options);
+ if (result != BS_OPERATION_OK)
+ goto cleanup;
+ processed += (int64_t)chunk;
+ new_position += (int64_t)chunk;
+ }
+ old_position = next_old_position;
+
+ if (control[1] > new_size - new_position) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+ processed = 0;
+ while (processed < control[1]) {
+ size_t chunk = (size_t)(control[1] - processed);
+ if (chunk > STREAM_CHUNK)
+ chunk = STREAM_CHUNK;
+ result = read_bzip_exact(
+ compressed,
+ diff_buffer,
+ chunk,
+ options);
+ if (result != BS_OPERATION_OK)
+ goto cleanup;
+ result = write_exact(
+ output_fd,
+ diff_buffer,
+ chunk,
+ options);
+ if (result != BS_OPERATION_OK)
+ goto cleanup;
+ processed += (int64_t)chunk;
+ new_position += (int64_t)chunk;
+ }
+ if (checked_add(
+ old_position,
+ control[2],
+ &next_old_position) != 0) {
+ result = BS_OPERATION_ERROR;
+ goto cleanup;
+ }
+ old_position = next_old_position;
+ report_progress(
+ options,
+ BS_OPERATION_PROCESSING,
+ new_size == 0
+ ? 0.85
+ : 0.15 + 0.70 *
+ ((double)new_position / (double)new_size));
+ }
+
+ if (fsync(output_fd) != 0)
+ goto cleanup;
+ report_progress(options, BS_OPERATION_WRITING, 0.95);
+ result = BS_OPERATION_OK;
+
+cleanup:
+ if (compressed != NULL)
+ BZ2_bzReadClose(&bz_error, compressed);
+ if (patch != NULL)
+ fclose(patch);
+ if (old_fd >= 0)
+ close(old_fd);
+ if (output_fd >= 0 && close(output_fd) != 0 &&
+ result == BS_OPERATION_OK)
+ result = BS_OPERATION_ERROR;
+ free(diff_buffer);
+ free(old_buffer);
+ return result;
+}
diff --git a/cpp/bspatch_streaming.h b/cpp/bspatch_streaming.h
new file mode 100644
index 0000000..17f6e22
--- /dev/null
+++ b/cpp/bspatch_streaming.h
@@ -0,0 +1,12 @@
+#ifndef BSPATCH_STREAMING_H
+#define BSPATCH_STREAMING_H
+
+#include "bsdiffpatch_operation.h"
+
+int bsPatchFileStreamingToFd(
+ const char *old_file,
+ const char *patch_file,
+ int output_fd,
+ const struct bs_operation_options *options);
+
+#endif
diff --git a/cpp/fuzz/bspatch_fuzzer.c b/cpp/fuzz/bspatch_fuzzer.c
index 362b8aa..7c68a70 100644
--- a/cpp/fuzz/bspatch_fuzzer.c
+++ b/cpp/fuzz/bspatch_fuzzer.c
@@ -51,6 +51,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
input.data = data + 2 + copied_old_size;
input.size = size - 2 - copied_old_size;
input.offset = 0;
+ memset(&stream, 0, sizeof(stream));
stream.opaque = &input;
stream.read = fuzz_read;
diff --git a/cpp/tests/native_operations_test.c b/cpp/tests/native_operations_test.c
index 7968f78..ad00b88 100644
--- a/cpp/tests/native_operations_test.c
+++ b/cpp/tests/native_operations_test.c
@@ -9,6 +9,7 @@
#endif
#include "bsdiff.h"
+#include "bsdiff40_converter.h"
#include "bsdiffpatch_operation.h"
#include "bspatch.h"
@@ -94,6 +95,110 @@ static int write_fixture(const char *path, int modified)
return fclose(file);
}
+static void encode_offset(int64_t value, uint8_t *buffer)
+{
+ int64_t magnitude = value < 0 ? -value : value;
+ int index;
+ for (index = 0; index < 8; index++) {
+ buffer[index] = (uint8_t)(magnitude & 0xff);
+ magnitude >>= 8;
+ }
+ if (value < 0)
+ buffer[7] |= 0x80;
+}
+
+static int write_bzip_block(FILE *file, const uint8_t *data, int length)
+{
+ int error;
+ BZFILE *stream = BZ2_bzWriteOpen(&error, file, 9, 0, 0);
+ if (stream == NULL || error != BZ_OK)
+ return -1;
+ if (length > 0) {
+ BZ2_bzWrite(&error, stream, (void *)data, length);
+ if (error != BZ_OK) {
+ BZ2_bzWriteClose(&error, stream, 1, NULL, NULL);
+ return -1;
+ }
+ }
+ BZ2_bzWriteClose(&error, stream, 0, NULL, NULL);
+ return error == BZ_OK ? 0 : -1;
+}
+
+static int write_bsdiff40_fixture(
+ const char *path,
+ const char *target_path)
+{
+ FILE *target = NULL;
+ FILE *patch = NULL;
+ uint8_t *target_data = NULL;
+ uint8_t header[32];
+ uint8_t control[24];
+ long control_end;
+ long diff_end;
+ int result = -1;
+
+ memset(header, 0, sizeof(header));
+ memset(control, 0, sizeof(control));
+ target_data = malloc(FIXTURE_SIZE);
+ target = fopen(target_path, "rb");
+ patch = fopen(path, "wb+");
+ if (target_data == NULL || target == NULL || patch == NULL ||
+ fread(target_data, 1, FIXTURE_SIZE, target) != FIXTURE_SIZE ||
+ fwrite(header, 1, sizeof(header), patch) != sizeof(header))
+ goto cleanup;
+ encode_offset(FIXTURE_SIZE, control + 8);
+ if (write_bzip_block(patch, control, sizeof(control)) != 0)
+ goto cleanup;
+ control_end = ftell(patch);
+ if (control_end < 32 || write_bzip_block(patch, NULL, 0) != 0)
+ goto cleanup;
+ diff_end = ftell(patch);
+ if (diff_end < control_end ||
+ write_bzip_block(patch, target_data, FIXTURE_SIZE) != 0)
+ goto cleanup;
+
+ memcpy(header, "BSDIFF40", 8);
+ encode_offset(control_end - 32, header + 8);
+ encode_offset(diff_end - control_end, header + 16);
+ encode_offset(FIXTURE_SIZE, header + 24);
+ if (fseek(patch, 0, SEEK_SET) != 0 ||
+ fwrite(header, 1, sizeof(header), patch) != sizeof(header))
+ goto cleanup;
+ result = 0;
+
+cleanup:
+ if (target != NULL)
+ fclose(target);
+ if (patch != NULL && fclose(patch) != 0)
+ result = -1;
+ free(target_data);
+ return result;
+}
+
+static int write_nonprogress_patch(const char *path)
+{
+ FILE *patch = NULL;
+ uint8_t header[24];
+ uint8_t control[24];
+ int result = -1;
+
+ memset(header, 0, sizeof(header));
+ memset(control, 0, sizeof(control));
+ memcpy(header, "ENDSLEY/BSDIFF43", 16);
+ encode_offset(1, header + 16);
+ patch = fopen(path, "wb");
+ if (patch == NULL ||
+ fwrite(header, 1, sizeof(header), patch) != sizeof(header) ||
+ write_bzip_block(patch, control, sizeof(control)) != 0)
+ goto cleanup;
+ result = 0;
+
+cleanup:
+ if (patch != NULL && fclose(patch) != 0)
+ result = -1;
+ return result;
+}
+
static int files_equal(const char *leftPath, const char *rightPath)
{
FILE *left = fopen(leftPath, "rb");
@@ -158,11 +263,16 @@ int main(void)
char restoredPath[512];
char limitedPath[512];
char cancelledPath[512];
+ char cancelledPatchOutputPath[512];
char corruptPatchPath[512];
char corruptOutputPath[512];
char racedPath[512];
char legacyPatchPath[512];
char legacyRestoredPath[512];
+ char bsdiff40PatchPath[512];
+ char convertedPatchPath[512];
+ char convertedRestoredPath[512];
+ char nonprogressPatchPath[512];
char *directory = mkdtemp(directoryTemplate);
struct callback_state state;
struct bs_operation_options options;
@@ -175,11 +285,20 @@ int main(void)
snprintf(restoredPath, sizeof(restoredPath), "%s/restored.bin", directory);
snprintf(limitedPath, sizeof(limitedPath), "%s/limited.bin", directory);
snprintf(cancelledPath, sizeof(cancelledPath), "%s/cancelled.patch", directory);
+ snprintf(
+ cancelledPatchOutputPath,
+ sizeof(cancelledPatchOutputPath),
+ "%s/cancelled-output.bin",
+ directory);
snprintf(corruptPatchPath, sizeof(corruptPatchPath), "%s/corrupt.patch", directory);
snprintf(corruptOutputPath, sizeof(corruptOutputPath), "%s/corrupt-output.bin", directory);
snprintf(racedPath, sizeof(racedPath), "%s/raced.patch", directory);
snprintf(legacyPatchPath, sizeof(legacyPatchPath), "%s/legacy.patch", directory);
snprintf(legacyRestoredPath, sizeof(legacyRestoredPath), "%s/legacy-restored.bin", directory);
+ snprintf(bsdiff40PatchPath, sizeof(bsdiff40PatchPath), "%s/legacy-bsdiff40.patch", directory);
+ snprintf(convertedPatchPath, sizeof(convertedPatchPath), "%s/converted.patch", directory);
+ snprintf(convertedRestoredPath, sizeof(convertedRestoredPath), "%s/converted.bin", directory);
+ snprintf(nonprogressPatchPath, sizeof(nonprogressPatchPath), "%s/nonprogress.patch", directory);
CHECK(write_fixture(oldPath, 0) == 0, "old fixture creation failed");
CHECK(write_fixture(newPath, 1) == 0, "new fixture creation failed");
@@ -195,6 +314,12 @@ int main(void)
"legacy patch accepted malformed input");
CHECK(access(corruptOutputPath, F_OK) != 0,
"legacy malformed patch committed an output");
+ CHECK(write_nonprogress_patch(nonprogressPatchPath) == 0,
+ "non-progress patch creation failed");
+ CHECK(bsPatchFile(oldPath, corruptOutputPath, nonprogressPatchPath) != BS_OPERATION_OK,
+ "non-progress patch was accepted");
+ CHECK(access(corruptOutputPath, F_OK) != 0,
+ "non-progress patch committed an output");
CHECK(bsDiffFile(oldPath, newPath, legacyPatchPath) == BS_OPERATION_OK,
"legacy diff failed");
CHECK(bsPatchFile(oldPath, legacyRestoredPath, legacyPatchPath) == BS_OPERATION_OK,
@@ -202,6 +327,15 @@ int main(void)
CHECK(files_equal(newPath, legacyRestoredPath),
"legacy round trip differs from fixture");
+ CHECK(write_bsdiff40_fixture(bsdiff40PatchPath, newPath) == 0,
+ "BSDIFF40 fixture creation failed");
+ CHECK(bsConvertBsdiff40File(bsdiff40PatchPath, convertedPatchPath) == 0,
+ "BSDIFF40 conversion failed");
+ CHECK(bsPatchFile(oldPath, convertedRestoredPath, convertedPatchPath) ==
+ BS_OPERATION_OK, "converted patch could not be applied");
+ CHECK(files_equal(newPath, convertedRestoredPath),
+ "converted BSDIFF40 patch differs from target");
+
memset(&state, 0, sizeof(state));
state.last_phase = -1;
state.monotonic = 1;
@@ -234,6 +368,36 @@ int main(void)
CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE, "output limit returned the wrong status");
CHECK(access(limitedPath, F_OK) != 0, "output limit committed an output");
+ memset(&state, 0, sizeof(state));
+ options = options_for(&state);
+ options.max_input_bytes = 0;
+ options.limit_flags = BS_OPERATION_LIMIT_INPUT;
+ result = bsDiffFileWithOptions(oldPath, newPath, limitedPath, &options);
+ CHECK(result == BS_OPERATION_INPUT_TOO_LARGE,
+ "explicit zero input limit returned the wrong status");
+ CHECK(access(limitedPath, F_OK) != 0,
+ "explicit zero input limit committed an output");
+
+ memset(&state, 0, sizeof(state));
+ options = options_for(&state);
+ options.max_output_bytes = 0;
+ options.limit_flags = BS_OPERATION_LIMIT_OUTPUT;
+ result = bsDiffFileWithOptions(oldPath, newPath, limitedPath, &options);
+ CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE,
+ "explicit zero diff output limit returned the wrong status");
+ CHECK(access(limitedPath, F_OK) != 0,
+ "explicit zero diff output limit committed an output");
+
+ memset(&state, 0, sizeof(state));
+ options = options_for(&state);
+ options.max_output_bytes = 0;
+ options.limit_flags = BS_OPERATION_LIMIT_OUTPUT;
+ result = bsPatchFileWithOptions(oldPath, limitedPath, patchPath, &options);
+ CHECK(result == BS_OPERATION_OUTPUT_TOO_LARGE,
+ "explicit zero patch output limit returned the wrong status");
+ CHECK(access(limitedPath, F_OK) != 0,
+ "explicit zero patch output limit committed an output");
+
memset(&state, 0, sizeof(state));
state.cancel_during_processing = 1;
state.last_phase = -1;
@@ -244,6 +408,23 @@ int main(void)
CHECK(access(cancelledPath, F_OK) != 0, "cancelled operation committed an output");
CHECK(!has_temporary_output(directory), "cancelled operation leaked a temporary file");
+ memset(&state, 0, sizeof(state));
+ state.cancel_during_processing = 1;
+ state.last_phase = -1;
+ state.monotonic = 1;
+ options = options_for(&state);
+ result = bsPatchFileStreamingWithOptions(
+ oldPath,
+ cancelledPatchOutputPath,
+ patchPath,
+ &options);
+ CHECK(result == BS_OPERATION_CANCELLED,
+ "streaming patch cancellation returned the wrong status");
+ CHECK(access(cancelledPatchOutputPath, F_OK) != 0,
+ "cancelled streaming patch committed an output");
+ CHECK(!has_temporary_output(directory),
+ "cancelled streaming patch leaked a temporary file");
+
CHECK(write_fixture(limitedPath, 0) == 0, "destination fixture creation failed");
memset(&state, 0, sizeof(state));
options = options_for(&state);
@@ -273,6 +454,10 @@ int main(void)
unlink(racedPath);
unlink(legacyPatchPath);
unlink(legacyRestoredPath);
+ unlink(bsdiff40PatchPath);
+ unlink(convertedPatchPath);
+ unlink(convertedRestoredPath);
+ unlink(nonprogressPatchPath);
CHECK(rmdir(directory) == 0, "temporary directory cleanup failed");
printf("native operation controls: ok\n");
return 0;
diff --git a/docs/README.md b/docs/README.md
index 9a535e2..3662551 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,14 +18,19 @@ The [Chinese documentation](./zh-CN/README.md) mirrors the same public guides.
## Guides
+- [Web and desktop WebView SDK](./web-sdk.md) — explicit ESM `/web` and
+ `/toolkit` entries, Vite/Tauri resources, lifecycle, limits, CSP, and
+ packaging checks.
- [Getting started](./getting-started.md) — installation and a first native or Web round trip.
- [API reference](./api-reference.md) — signatures, inputs, outputs, and errors.
- [Production recipes](./recipes.md) — integrity, cleanup, downloads, and cross-runtime workflows.
+- [Verified Delta Pipeline](./verified-delta-pipeline.md) — Node CLI,
+ manifests, multi-baseline bundles, release selection, and GitHub Actions.
- [Platform support](./platform-support.md) — architecture and bundler behavior.
- [Architecture](./architecture.md) — execution paths and patch compatibility.
- [Controllable native operations](./native-operations-v03.md) — resource limits,
cancellation, progress, and atomic output contract.
-- [Large-file roadmap](./large-files-v04.md) — memory baselines, honest progress,
+- [Large-file roadmap](./large-files-roadmap.md) — memory baselines, honest progress,
and streaming feasibility for the next architecture iteration.
- [Troubleshooting](./troubleshooting.md) — common integration failures.
- [Development](./development.md) — local builds, tests, WebAssembly, and release checks.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 19b66d4..49c0877 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -1,7 +1,11 @@
# API reference
-The package exposes two platform-specific API families from the same import
-path. Native runtimes use absolute paths; Web uses in-memory binary values.
+The package exposes two platform-specific API families. Existing shared code
+may import the root package and rely on its React Native/browser conditions;
+standalone browser and desktop WebView consumers should use the explicit ESM
+entry `react-native-bs-diff-patch/web`. Native runtimes use absolute paths; Web
+uses in-memory binary values. The platform-neutral manifest helpers live in
+the ESM entry `react-native-bs-diff-patch/toolkit`.
```ts
import {
@@ -9,10 +13,13 @@ import {
patch,
startDiff,
startPatch,
+ startDiffBytes,
+ startPatchBytes,
diffBytes,
patchBytes,
inspectPatch,
verifyPatch,
+ classifyPatchError,
type BinaryInput,
type BinaryOperationOptions,
type PatchMetadata,
@@ -58,7 +65,7 @@ Reconstructs the target file at `outputFile`. Available on Android and iOS.
- Resolves to `0` on success.
- Rejects rather than overwriting an existing `outputFile`.
-## `startDiff` and `startPatch`
+## Native `startDiff` and `startPatch`
```ts
interface NativeOperationOptions {
@@ -115,6 +122,7 @@ interface BinaryOperationOptions {
signal?: AbortSignal;
maxInputBytes?: number;
maxOutputBytes?: number;
+ onProgress?: (event: BinaryOperationProgress) => void;
}
function diffBytes(
@@ -127,7 +135,10 @@ function diffBytes(
Creates a binary patch in a Web Worker. Available on Web.
- Accepts `ArrayBuffer`, any typed-array or `DataView`, and `Blob`.
-- Copies inputs, so buffers owned by the caller are not detached.
+- Zero-byte binary inputs are valid; native path APIs separately reject empty
+ path strings.
+- Preserves caller-owned buffers. `Blob` and `File` inputs are mounted
+ read-only through WORKERFS instead of being copied in full on the main thread.
- Resolves to a new `Uint8Array` containing an `ENDSLEY/BSDIFF43` patch.
- Checks each input against `maxInputBytes` and the generated patch against
`maxOutputBytes` when those limits are configured.
@@ -146,7 +157,9 @@ Applies a compatible patch in a Web Worker and resolves to the reconstructed
bytes. Available on Web.
- Validates the patch header before invoking the WebAssembly core.
-- Copies inputs and resolves to a new `Uint8Array`.
+- An empty baseline or target buffer is valid when the patch format permits it;
+ malformed patch input is rejected.
+- Preserves inputs and resolves to a new `Uint8Array`.
- Does not mutate `oldData` or `patchData`.
- Rejects before allocating the declared output when the patch header exceeds
`maxOutputBytes`.
@@ -236,6 +249,8 @@ byte-for-byte.
dedicated Worker so aborting it cannot interrupt another request.
- `maxInputBytes` limits each supplied binary input, not their sum.
- `maxOutputBytes` limits the generated patch or restored output.
+- `onProgress` receives real `reading`, `processing`, and `writing` checkpoints
+ emitted by the C core.
- Limits must be non-negative safe integers. Invalid limits reject with
`EINVAL`; exceeded limits reject with `ERESOURCE`.
@@ -243,13 +258,44 @@ The binary APIs accept the options argument on native only to keep shared
wrappers source-compatible, then reject with `EUNSUPPORTED` as usual. Native
path operations use `startDiff` or `startPatch` for equivalent controls.
+## Web jobs
+
+On Web, `startDiff()` and `startPatch()` accept binary inputs and return a job
+whose result is `Promise`. `startDiffBytes()` and
+`startPatchBytes()` are explicit aliases for shared cross-platform wrappers.
+
+```ts
+const job = startPatchBytes(oldFile, patchFile, {
+ maxOutputBytes: 128 * 1024 * 1024,
+});
+
+const unsubscribe = job.onProgress(({ phase, progress }) => {
+ renderProgress(phase, progress);
+});
+
+try {
+ const restored = await job.result;
+ // await job.cancel();
+} finally {
+ unsubscribe();
+}
+```
+
+Job cancellation terminates only its dedicated Worker. `result` rejects with
+`EABORTED`, while `cancel()` resolves after that result reaches a terminal
+state and the Worker/listener cleanup has completed. Calling `cancel()` again
+after completion is safe and does not change the settled result. Progress is
+sourced from the C/WASM operation and never simulated.
+
## Availability behavior
All functions remain exported so shared code has one stable import shape.
Calling `diffBytes` or `patchBytes` on native rejects with `EUNSUPPORTED`.
-Calling `diff`, `patch`, `startDiff`, or `startPatch` on Web behaves the same
-way. `inspectPatch` and `verifyPatch` are available on every platform, but they
-require native paths on Android/iOS and binary values on Web.
+Calling `diff` or `patch` on Web rejects with `EUNSUPPORTED`; binary
+`startDiff` and `startPatch` are available. `startDiffBytes` and
+`startPatchBytes` reject on native. `inspectPatch` and `verifyPatch` are
+available on every platform, but require native paths on Android/iOS and binary
+values on Web.
Importing the Web entry during server-side rendering does not start a Worker.
Calling a binary API without browser Worker support rejects with
@@ -266,7 +312,7 @@ type PatchError = Error & { code?: string };
| Code | Meaning |
| ------------------- | ----------------------------------------------------------- |
-| `EINVAL` | Empty, duplicate, or invalid input. |
+| `EINVAL` | Native empty/duplicate paths or invalid input/options; zero-byte binary inputs are valid. |
| `ENOENT` | A required native file does not exist. |
| `EEXIST` | A native output path already exists. |
| `EUNSUPPORTED` | The selected API is not available on the current platform. |
@@ -284,6 +330,11 @@ type PatchError = Error & { code?: string };
Treat error messages as diagnostic text rather than a stable machine-readable
contract. Branch on `code` when recovery behavior differs.
+`classifyPatchError(error)` normalizes platform-specific codes into
+`ABORTED`, `RESOURCE`, `INVALID_ARGUMENT`, `INVALID_PATCH`, `VERIFICATION`,
+`DESTINATION`, `UNSUPPORTED`, or `RUNTIME`, while preserving the original
+code and message.
+
Native validation stops before entering the C core. Web failures related to
Worker startup, patch validation, or WebAssembly execution use
`EWEBASSEMBLY` unless a more specific code is available.
@@ -300,5 +351,10 @@ for large browser inputs.
## Patch format
-All operations read or write `ENDSLEY/BSDIFF43` patches. Other bsdiff
-variants, such as patches beginning with `BSDIFF40`, are not interchangeable.
+Runtime operations read or write `ENDSLEY/BSDIFF43` patches. Other bsdiff
+variants are not accepted automatically. Convert existing `BSDIFF40` files
+offline with the Node CLI, then verify them before release:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+```
diff --git a/docs/architecture.md b/docs/architecture.md
index b1965ba..5fb220b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -17,7 +17,7 @@ React Native Web
-> typed public API
-> shared or cancellation-scoped module Web Worker
-> Emscripten MEMFS
- -> the same bsdiff + bzip2 C sources compiled to WebAssembly
+ -> the same bsdiff + bzip2 C sources compiled to browser WebAssembly
```
The worker boundaries keep expensive binary work away from the JavaScript/UI
@@ -45,9 +45,10 @@ Patches begin with a 24-byte header:
| `16..23` | Signed 64-bit target size in the format's byte order |
| `24..` | bzip2-compressed control, diff, and extra data |
-The Web adapter validates the header and signature before entering the C patch
-function. Native and Web operations use the same checked-in bsdiff and bzip2
-sources, preserving cross-platform patch compatibility.
+The Web adapter validates the header magic and declared target size before
+entering the C patch function. Native and Web operations use the same
+checked-in bsdiff and bzip2 sources, preserving cross-platform patch
+compatibility.
The format identifies the patch implementation, but not the intended baseline
or release. Applications should carry baseline and target digests in a trusted
@@ -55,16 +56,17 @@ manifest when distributing patches.
## WebAssembly packaging
-`scripts/build-web-wasm.sh` invokes Emscripten with:
+`scripts/build-web-wasm.sh` invokes Emscripten twice with the same C sources:
-- an ES module factory;
-- a single-file embedded WebAssembly payload;
-- memory growth enabled;
-- MEMFS and the `FS`/`ccall` runtime methods;
-- exported `bsDiffFile` and `bsPatchFile` functions.
+- `web/bsdiffpatch.mjs`: Node-compatible ES module factory with NODEFS for the
+ `/node` entry and CLI;
+- `web/bsdiffpatch.browser.mjs`: Node-free browser/Worker ES module factory;
+- both builds use a single-file embedded WebAssembly payload, memory growth,
+ MEMFS, the `FS`/`ccall` runtime methods, and the patch operation exports.
-The generated `web/bsdiffpatch.mjs` is published with the package. Consumers do
-not need Emscripten.
+Both generated modules are published with the package. The `/web` resource
+graph reaches only the browser module, while `/node` retains the Node module;
+consumers do not need Emscripten.
## Compatibility verification
@@ -113,9 +115,10 @@ runner family. The checked-in record is
## Memory model
Native operations read the old and target files into process memory. Web calls
-copy inputs before transferring them to a Worker, then copy results out of
-MEMFS. Peak memory can therefore be several times larger than the input or
-output size. The native reference reaches roughly nineteen times the input size
+copy ArrayBuffer and typed-array inputs into Worker MEMFS; Blob and File inputs
+use a read-only WORKERFS mount. Results are copied out of MEMFS. Peak memory can
+therefore be several times larger than the input or output size. The native
+reference reaches roughly nineteen times the input size
for this highly similar 50 MiB fixture, primarily because of the suffix array
and simultaneous file buffers.
diff --git a/docs/development.md b/docs/development.md
index ae2bd8a..67e5869 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -34,6 +34,7 @@ yarn test:web
yarn test:web:browser
yarn test:web:metro
yarn test:package
+yarn test:sdk
```
- `test:web` checks the WebAssembly round trip and patch magic.
@@ -42,6 +43,9 @@ yarn test:package
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.
## Native robustness and compatibility
@@ -88,7 +92,7 @@ The large profile uses 16, 64, and 128 MiB fixtures and can consume several
gigabytes of memory. It is intentionally not a pull-request gate. A manual
`Native Core Benchmark` run accepts a comma-separated size list when a shared
runner baseline is useful. Interpret the numbers with the scope and acceptance
-criteria in the [large-file roadmap](./large-files-v04.md).
+criteria in the [large-file roadmap](./large-files-roadmap.md).
The published-package canaries install directly from npm and intentionally use
current Vite and Expo toolchains. They are scheduled CI checks, not release
@@ -135,7 +139,11 @@ yarn test:web
yarn test:web:browser
```
-Commit the regenerated `web/bsdiffpatch.mjs` with the C source change.
+Commit both generated modules with the C source change. The Node-compatible
+`web/bsdiffpatch.mjs` includes NODEFS for `/node` and the CLI; the dedicated
+browser `web/bsdiffpatch.browser.mjs` excludes Node runtime branches for the
+`/web` Worker graph. Do not replace one with the other to hide a bundler
+warning.
## Native verification
@@ -156,11 +164,14 @@ For local example commands, see [CONTRIBUTING.md](../CONTRIBUTING.md).
## Publishing checklist
1. Run the core, Web, and site gates.
-2. Run `yarn test:package` and inspect `npm pack --dry-run --ignore-scripts`.
+2. Run `yarn test:package`, `yarn test:sdk`, and inspect `npm pack --dry-run`.
+ The pack command runs the `prepack` contract check; use
+ `node scripts/check-package-contract.mjs` for a direct check.
3. Confirm public docs match the exported TypeScript declarations.
4. Confirm English and Chinese public guides describe the same behavior.
-5. Use `yarn release` to create the version, tag, and GitHub Release. It does not
- publish directly to npm.
+5. When the prepared `package.json` version is final, use
+ `yarn release --no-increment` to create the release commit, tag, and GitHub
+ Release. Run it only with explicit maintainer authorization.
6. Publishing the GitHub Release starts `npm-publish.yml`. The workflow checks
that the tag matches `package.json`, runs the release gates, publishes through
npm Trusted Publishing, and verifies the provenance attestation.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 3196649..846f8ce 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -19,6 +19,10 @@ React Native autolinking handles Android and iOS registration. Rebuild the
native application after installation; reloading Metro does not change the
native modules inside an already-installed binary.
+For a Vite app or desktop WebView, use the dedicated [Web and desktop WebView
+SDK](./web-sdk.md) guide. It uses the explicit ESM `/web` entry and does not
+require React Native or a Node sidecar.
+
## Choose the API for the runtime
| Runtime | Use | Do not use |
diff --git a/docs/large-files-v04.md b/docs/large-files-roadmap.md
similarity index 64%
rename from docs/large-files-v04.md
rename to docs/large-files-roadmap.md
index 223789d..1a5ee10 100644
--- a/docs/large-files-v04.md
+++ b/docs/large-files-roadmap.md
@@ -1,4 +1,4 @@
-# Large-file roadmap (v0.4)
+# Large-file roadmap
This document defines how the project will evaluate larger inputs, expose
honest progress, and investigate streaming without weakening patch
@@ -10,12 +10,14 @@ every browser or mobile device can process a particular file size.
The current diff algorithm needs random access to the complete old and new
inputs while building and traversing its suffix array. Native calls therefore
operate on file paths but still allocate memory proportional to the input. The
-Web implementation additionally moves complete buffers between JavaScript, a
-Worker, and WebAssembly linear memory.
+Web implementation still transfers typed-array inputs into a Worker and
+WebAssembly linear memory; `Blob`/`File` inputs avoid the extra main-thread copy
+through WORKERFS. Node release tools mount host paths through NODEFS.
-Patch application is less demanding than diff generation, but the current C
-and Web boundaries still materialize the complete operation state. Resource
-limits prevent unbounded work; they do not make the algorithm streaming.
+Patch application now reads the old file and compressed patch in 64 KiB
+chunks and writes a sibling temporary output incrementally. Web still returns a
+complete `Uint8Array`, so the browser boundary materializes the final result
+even though the C core no longer allocates complete old and output buffers.
## Measurement matrix
@@ -45,26 +47,24 @@ on lower-memory devices.
The initial Apple M3 Pro / Node 22 record is checked in under `benchmarks/`.
Native completed 128 MiB with approximately 2.37 GiB peak RSS. Web completed
-64 MiB with approximately 2.09 GiB peak RSS, while 128 MiB returned the generic
-`EWEBASSEMBLY` error. That generic failure remains an error-taxonomy gap and
-means the project does not currently claim 128 MiB Web diff support.
+64 MiB with approximately 2.09 GiB peak RSS, while 128 MiB exhausted the
+WebAssembly memory budget. Web classifies that failure as `ERESOURCE`; the
+project still does not claim 128 MiB Web diff support.
## Progress semantics
Progress must be produced by real algorithm checkpoints, never a timer or an
-animation that guesses completion. A future cross-platform operation can use
-the existing stages:
+animation that guesses completion. Cross-platform jobs use these stages:
- `reading`: validating inputs and loading the data required by the core.
- `processing`: suffix-array/diff work or patch reconstruction.
- `writing`: persisting and atomically committing native output; Web completes
this stage when the result buffer is ready to transfer.
-Native jobs already expose these stages. Web parity requires Worker messages
-emitted from instrumented C/WebAssembly boundaries. Until those checkpoints
-exist, Web should report only start, cancellation, and completion rather than
-synthetic percentages. The public callback remains optional and must not change
-the result or error behavior when it is absent.
+Native and Web jobs expose these stages. Web progress travels from instrumented
+C checkpoints through WebAssembly and Worker messages; no timer or synthetic
+percentage is used. The public callback remains optional and does not change
+result or error behavior when absent.
## Streaming feasibility
@@ -73,21 +73,20 @@ algorithm: suffix-array construction and matching require global random access
to both inputs. Supporting it would mean selecting a different algorithm or a
new patch format, with an explicit compatibility and migration decision.
-Patch application is a better candidate for incremental work. A prototype can
-read the old file and compressed control/diff/extra streams in bounded chunks,
-write a temporary destination, and retain the current `ENDSLEY/BSDIFF43`
-contract. Browser support should start with `Blob`/`File` and an internal
-bounded reader; writable file handles can remain a progressive enhancement.
+Patch application uses a bounded C implementation that reads the old file and
+compressed control/diff/extra stream in chunks, writes a temporary destination,
+and retains the `ENDSLEY/BSDIFF43` contract. `Blob`/`File` inputs use read-only
+WORKERFS mounts in browsers. Direct browser file-handle output remains a
+progressive enhancement because the public API still returns a complete buffer.
## Delivery sequence
1. Keep 16/64/128 MiB time and peak-memory baselines for native and Web.
-2. Instrument core checkpoints and add truthful Web progress events without
- changing the existing `diff`, `patch`, or `startPatch` contracts.
-3. Prototype file-backed, incremental patch application and prove cancellation,
- resource limits, temporary cleanup, and byte-for-byte compatibility.
-4. Decide whether the measured benefit justifies a new public API. Treat a
- streaming diff algorithm or new patch format as a separate proposal.
+2. Measure the completed C/WASM progress and bounded patch path on comparable
+ devices, including peak memory before and after the change.
+3. Evaluate direct browser writable-file output without weakening cancellation,
+ resource limits, cleanup, or byte-for-byte compatibility.
+4. Treat a streaming diff algorithm or new patch format as a separate proposal.
Any production API must preserve deterministic output validation, reject sizes
outside configured limits before large allocations where possible, clean up on
diff --git a/docs/native-operations-v03.md b/docs/native-operations-v03.md
index 072be28..d954d52 100644
--- a/docs/native-operations-v03.md
+++ b/docs/native-operations-v03.md
@@ -65,9 +65,11 @@ output. Existing `diff` and `patch` keep their established behavior.
## Platform behavior
-The job API is available on Android and iOS. React Native Web uses the binary
-`diffBytes` and `patchBytes` APIs with an `AbortSignal` and byte limits instead;
-calling `startDiff` or `startPatch` on Web rejects with `EUNSUPPORTED`.
+Android and iOS jobs accept file paths and resolve to `0`. React Native Web
+jobs accept binary inputs and resolve to `Uint8Array`; `startDiffBytes` and
+`startPatchBytes` are explicit aliases. Web cancellation terminates the job's
+dedicated Worker with `EABORTED`, while progress comes from the same C-core
+checkpoints.
The patch wire format remains `ENDSLEY/BSDIFF43`. Operation control changes
execution behavior, not patch compatibility.
diff --git a/docs/platform-support.md b/docs/platform-support.md
index 68968f6..e5ea86e 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -55,6 +55,13 @@ file under the system temporary directory and removes it on every exit path.
## React Native Web
+Standalone browser and desktop WebView applications should import the
+explicit ESM entry `react-native-bs-diff-patch/web`. Its Worker graph uses the
+Node-free `web/bsdiffpatch.browser.mjs`; the `/toolkit` entry is also ESM-only.
+The root package's `browser` condition remains available for existing React
+Native Web consumers. See [Web and desktop WebView SDK](./web-sdk.md) for
+resource and CSP requirements.
+
The package has two Web entry mechanisms:
- `browser` points standard browser-aware bundlers to `web/index.mjs`.
@@ -73,15 +80,17 @@ Webpack and Vite understand the standard
`new Worker(new URL(..., import.meta.url), { type: 'module' })` pattern. A Metro
Web setup must preserve module-worker URLs in its Web serializer.
-The Web entry is browser-oriented rather than a Node.js filesystem adapter. It
-does not make the native file-path APIs available in Node.js.
-Native job functions remain exported for a stable import shape but reject with
-`EUNSUPPORTED` on Web.
+The Web entry is browser-oriented rather than a Node.js filesystem adapter.
+It does not make native file-path APIs available in the browser. `startDiff`
+and `startPatch` use binary inputs on Web; the separate package `./node` entry
+provides release-side filesystem operations.
Calls without an `AbortSignal` share a module Worker and initialized
WebAssembly module. Calls with a signal receive a dedicated Worker so
cancellation is isolated to that operation. Both paths serialize work inside
their Worker; callers should still enforce an application memory budget.
+`Blob` and `File` inputs use read-only WORKERFS mounts to avoid a full
+main-thread copy.
`inspectPatch` does not start a Worker. `verifyPatch` first validates metadata,
then uses the same Worker path as `patchBytes` and discards the restored buffer
after comparing it with the expected input.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 0e40da9..517c31d 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -69,9 +69,9 @@ Confirm the bundler emits module-worker assets and that the deployed server
serves `.mjs` files as JavaScript. Strict Content Security Policy deployments
must permit same-origin workers and WebAssembly execution.
-Open the browser network panel and confirm `worker.mjs`, `operations.mjs`, and
-`bsdiffpatch.mjs` are returned with successful status codes rather than the
-application HTML fallback.
+Open the browser network panel and confirm `worker.browser.mjs`,
+`operations.browser.mjs`, and `bsdiffpatch.browser.mjs` are returned with
+successful status codes rather than the application HTML fallback.
## `EPATCH`, `EWEBASSEMBLY`, or corrupt patch
diff --git a/docs/verified-delta-pipeline.md b/docs/verified-delta-pipeline.md
new file mode 100644
index 0000000..fb6f935
--- /dev/null
+++ b/docs/verified-delta-pipeline.md
@@ -0,0 +1,141 @@
+# Verified Delta Pipeline
+
+The package can be used as a client runtime, a release-side Node tool, or both.
+The shared manifest and bundle schema connects patch generation, CDN selection,
+and verified restore without owning transport or private signing keys.
+
+## Node CLI
+
+Install the package in a release workspace or run it through `npx`:
+
+```sh
+npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch
+npx react-native-bs-diff-patch inspect update.patch --json
+npx react-native-bs-diff-patch verify old.bin update.patch new.bin
+npx react-native-bs-diff-patch manifest \
+ old.bin update.patch new.bin -o patch-manifest.json
+```
+
+The CLI refuses to overwrite existing output files. Node runs the same
+WebAssembly core shipped for Web and mounts host paths through NODEFS, avoiding
+an additional full-file copy in JavaScript. Diff generation still indexes
+complete inputs inside the C/WASM core; patch application and verification use
+the bounded streaming file path.
+
+## Verified patch manifest
+
+`createPatchManifest()` and `validatePatchManifest()` are available from the
+environment-neutral toolkit entry:
+
+```ts
+import {
+ canonicalJson,
+ createPatchManifest,
+ signingPayload,
+} from 'react-native-bs-diff-patch/toolkit';
+
+const manifest = createPatchManifest({
+ baseline: { bytes: 1000, sha256: baselineSha256 },
+ patch: { bytes: 120, sha256: patchSha256, url: 'update.patch' },
+ target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' },
+});
+
+const bytesToSign = new TextEncoder().encode(signingPayload(manifest));
+const canonical = canonicalJson(manifest);
+```
+
+The library canonicalizes JSON, validates SHA-256 descriptors, and carries
+detached-signature metadata. It never loads, stores, or manages a private key.
+Authenticate the canonical manifest through the signing system already used by
+your release pipeline.
+
+## Verified restore in Node
+
+The Node entry validates the baseline and patch before applying, then validates
+the restored target before keeping it:
+
+```ts
+import {
+ createFilePatchManifest,
+ restoreVerified,
+} from 'react-native-bs-diff-patch/node';
+
+const manifest = await createFilePatchManifest(
+ 'old.bin',
+ 'update.patch',
+ 'new.bin'
+);
+
+await restoreVerified('old.bin', 'update.patch', 'restored.bin', manifest);
+```
+
+A baseline, patch, or target mismatch rejects with a verification error and
+does not leave the requested output behind.
+
+## Multi-baseline bundle
+
+Generate one target release from every regular file in a baseline directory:
+
+```sh
+npx react-native-bs-diff-patch bundle \
+ --from releases/ \
+ --to dist/app.bin \
+ --out dist/update-bundle \
+ --max-ratio 0.85 \
+ --release-id v1.5.0
+```
+
+The output contains:
+
+- the full target fallback;
+- one `ENDSLEY/BSDIFF43` patch for each cost-effective baseline;
+- `bundle-manifest.json` for humans and CDNs;
+- `bundle-manifest.canonical.json` for signing;
+- a decision report showing patch or full-file selection.
+
+At runtime, `selectPatch()` matches the trusted baseline SHA-256 and applies
+optional patch-byte or patch-ratio budgets. A missing baseline or an
+uneconomical patch selects the full artifact explicitly.
+
+## GitHub Action
+
+The repository includes a dependency-free action for one baseline:
+
+```yaml
+- uses: JimmyDaddy/react-native-bs-diff-patch@v0.5.0
+ id: delta
+ with:
+ old-file: releases/v1.bin
+ new-file: dist/app.bin
+ patch-file: dist/update.patch
+ manifest-file: dist/patch-manifest.json
+ max-patch-ratio: '0.85'
+
+- run: echo "strategy=${{ steps.delta.outputs.strategy }}"
+```
+
+Use the CLI `bundle` command when the release needs several baselines. The
+action exposes patch size, target size, ratio, savings, and the selected
+`patch` or `full` strategy as outputs.
+
+## BSDIFF40 migration
+
+The runtime continues to accept only `ENDSLEY/BSDIFF43`. Existing `BSDIFF40`
+files can be converted offline without the baseline file:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+npx react-native-bs-diff-patch inspect compatible.patch
+```
+
+Conversion validates the three BSDIFF40 compressed blocks, rewrites them into
+the interleaved BSDIFF43 stream, and refuses malformed or existing outputs.
+Verify the converted patch against its known baseline and target before
+publishing it.
+
+## Browser Release Planner
+
+The [Release Planner](https://bs-dff-patch.corerobin.com/planner/) generates a
+multi-baseline matrix and the same bundle manifest entirely in the browser. It
+is intended for evaluation and debugging; use the CLI to reproduce production
+artifacts in CI. Files selected in the planner are not uploaded.
diff --git a/docs/web-sdk.md b/docs/web-sdk.md
new file mode 100644
index 0000000..3904a38
--- /dev/null
+++ b/docs/web-sdk.md
@@ -0,0 +1,333 @@
+# Web and desktop WebView SDK
+
+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.
+
+## 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
+`new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })`
+relationship.
+
+## Minimal Vite or Tauri round trip
+
+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
+```
+
+For pre-release verification of a locally prepared package, substitute its
+tarball:
+
+```sh
+npm install ./react-native-bs-diff-patch-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.
+
+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,
+React Native, Node, or a server endpoint:
+
+```ts
+import {
+ diffBytes,
+ inspectPatch,
+ patchBytes,
+ verifyPatch,
+} from 'react-native-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 controller = new AbortController();
+const patch = await diffBytes(baseline, target, {
+ signal: controller.signal,
+ 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, {
+ maxInputBytes: 32 * 1024 * 1024,
+ maxOutputBytes: 32 * 1024 * 1024,
+});
+
+if (!verification.verified || restored.length !== target.length) {
+ throw new Error('restored bytes do not match the target');
+}
+```
+
+The same functions accept an `ArrayBuffer`, any `ArrayBufferView` (including a
+`DataView`), or a `Blob`/`File`. A file selected by the browser can
+therefore be passed directly:
+
+```ts
+const patch = await diffBytes(oldFile, newFile);
+const patchBlob = new Blob([patch.slice().buffer as ArrayBuffer]);
+const restored = await patchBytes(oldFile, patchBlob);
+```
+
+The result is a new `Uint8Array`. Typed-array offsets and lengths are honored,
+and the input buffers remain usable after the call. The Worker does not take
+ownership of caller buffers. For `Blob` and `File`, the Worker uses a
+read-only WORKERFS mount while the C core reads the object; this avoids making
+a full additional main-thread copy before the operation starts. Keep patch
+bytes as binary data when storing or sending them; UTF-8 conversion corrupts
+arbitrary patch bytes.
+
+## Jobs, cancellation, and cleanup
+
+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';
+
+const job = startPatchBytes(oldFile, patchFile, {
+ maxInputBytes: 64 * 1024 * 1024,
+ maxOutputBytes: 128 * 1024 * 1024,
+ onProgress: renderProgress,
+});
+const unsubscribe = job.onProgress(renderProgress);
+
+cancelButton.onclick = () => void job.cancel();
+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` are
+binary job APIs in the Web entry. Their `result` resolves to a new
+`Uint8Array`, and `cancel()` is operation-local. A job cancellation
+terminates its dedicated Worker and rejects with `EABORTED`; it is not a
+Promise timeout and does not interrupt another job. `cancel()` resolves only
+after `result` reaches its terminal state and job cleanup has run. The `result`
+promise itself rejects with `EABORTED`. Repeated cancellation is safe;
+cancelling an already completed job does not change its settled result. A
+cancellation or failure does not return a partial result.
+
+The library removes operation-owned MEMFS files and listeners when a Worker
+operation settles. There is no public `dispose()` call for the shared Worker:
+calls without a signal reuse a module Worker and a cached WASM module, while a
+call with a signal uses a dedicated Worker that is terminated after settle.
+Applications still need to revoke their own `URL.createObjectURL()` URLs and
+release references to returned buffers when those values are no longer needed.
+
+Calls without a signal share one serialized Worker queue. Calls with a signal,
+including the `start*` job wrappers, use dedicated Workers so cancellation is
+isolated. The SDK does not enforce an aggregate application memory or
+concurrency budget; cap concurrent jobs in the application before starting
+large operations.
+
+## Limits and memory behavior
+
+`maxInputBytes` and `maxOutputBytes` are optional per-operation guards:
+
+- `maxInputBytes` applies separately to every supplied input. It is not a
+ total-memory or combined-input limit.
+- `maxOutputBytes` applies to a generated patch or reconstructed output. For a
+ patch operation, the declared target size is checked before decompression and
+ output allocation; the produced result is checked as well.
+- Limits must be non-negative safe integers. An invalid value rejects with
+ `EINVAL`; an exceeded byte limit rejects with `ERESOURCE`.
+
+The algorithm and WebAssembly adapter can use several times the input or
+output size. The generated browser WASM build currently retains Emscripten's
+configured maximum linear-memory setting of 2 GiB. This is a build setting,
+not a limit from the WebAssembly standard or a universal hard ceiling for
+every engine. It is not a promise that every browser, Tauri WebView, or device
+can allocate that much; a host can fail earlier because of its WebAssembly
+linear-memory or tab budget;
+detectable allocation and memory-access failures are classified as
+`ERESOURCE`, while other Worker or WebAssembly failures use
+`EWEBASSEMBLY`. Treat the host's measured ceiling as an environment
+constraint and record the tested input sizes for the target WebView. Do not
+present `maxInputBytes` as a guarantee about total process memory. Recheck this
+ceiling when the toolchain or generated WASM build changes.
+
+## Errors and trust boundaries
+
+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 |
+
+`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
+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
+before replacing application data.
+
+Runtime generation and application support `ENDSLEY/BSDIFF43`. A
+`BSDIFF40` input is recognized by header inspection as `format:
+'BSDIFF40'` with `valid: false` and `issue: 'LEGACY_FORMAT'`; it is not
+silently applied. The existing Node converter remains available for offline
+migration:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+```
+
+Verify the converted patch against its exact baseline and target before
+shipping it. A patch's format does not identify its intended baseline.
+
+## Toolkit manifests and candidate selection
+
+The toolkit has no filesystem, network, or private-key access. It validates and
+normalizes data supplied by the caller:
+
+```ts
+import {
+ canonicalJson,
+ createPatchBundle,
+ createPatchManifest,
+ selectPatch,
+ signingPayload,
+} from 'react-native-bs-diff-patch/toolkit';
+
+const manifest = createPatchManifest({
+ baseline: { bytes: 1000, sha256: baselineSha256 },
+ patch: { bytes: 120, sha256: patchSha256, url: 'release.patch' },
+ target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' },
+});
+const bytesToSign = new TextEncoder().encode(signingPayload(manifest));
+const canonical = canonicalJson(manifest);
+```
+
+`validatePatchManifest()` and `validatePatchBundle()` check structure, byte
+counts, SHA-256-shaped strings, format, and bundle target consistency. They do
+not read the named URLs, download artifacts, calculate hashes, verify a
+signature, or prove that the bytes match the descriptors. Unknown fields are
+discarded from the normalized return value, so keep application-specific data
+outside the validated schema or explicitly preserve it in your own envelope.
+
+`canonicalJson()` sorts object keys and omits `undefined` object properties.
+`signingPayload()` returns canonical JSON with the manifest's detached
+signature metadata removed. Neither function signs data: canonical JSON and a
+signing payload are inputs to an external cryptographic signing system, not a
+digital signature.
+
+`selectPatch()` first validates the bundle and the selection options, then:
+
+1. Lowercases the requested 64-character baseline SHA-256 and finds the first
+ candidate with that exact digest.
+2. Returns the full artifact with `BASELINE_NOT_FOUND` if no candidate matches.
+3. Returns the full artifact with `PATCH_BYTES_EXCEEDED` when
+ `maxPatchBytes` is exceeded.
+4. Returns the full artifact with `PATCH_RATIO_EXCEEDED` when
+ `candidate.patch.bytes / max(1, full.bytes)` is greater than
+ `maxPatchRatio`.
+5. Otherwise returns that first matching candidate with `BASELINE_MATCH` and
+ strategy `patch`.
+
+The helper does not search for the smallest patch or perform a restore. The
+application must authenticate the manifest, download the selected artifact,
+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.
+
+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;
+- 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
+ is disconnected;
+- the target WebView's CSP permits the Worker and WebAssembly execution;
+- Rust or another desktop layer owns file authorization and persistence, while
+ JavaScript passes bytes to the SDK.
+
+The minimum CSP additions for this SDK are:
+
+```text
+script-src 'self' 'wasm-unsafe-eval';
+worker-src 'self';
+```
+
+Merge those sources into the application's existing policy. Do not add
+ordinary `unsafe-eval`, load the engine from a CDN, or loosen the policy as a
+fallback. This guide documents the required policy; actual Tauri WebView
+acceptance remains a downstream application test.
+
+## Verification commands
+
+From the repository, the relevant local checks are:
+
+```sh
+yarn test:web
+yarn test:web:browser
+yarn test:web:metro
+yarn test:toolkit
+yarn test:sdk
+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
+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/README.md b/docs/zh-CN/README.md
index 50233fd..0ea3393 100644
--- a/docs/zh-CN/README.md
+++ b/docs/zh-CN/README.md
@@ -18,14 +18,18 @@
## 指南
+- [Web 与桌面 WebView SDK](./web-sdk.md) — `/web` 与 `/toolkit` ESM 入口、Vite/Tauri
+ 资源、生命周期、限制、CSP 与打包检查。
- [快速开始](/docs/zh-CN/getting-started/) — 安装并完成第一次原生端或 Web 往返。
- [API 参考](/docs/zh-CN/api-reference/) — 签名、输入、输出和错误码。
- [生产实践](/docs/zh-CN/recipes/) — 完整性、清理、下载与跨运行时流程。
+- [可验证增量发布工具链](/docs/zh-CN/verified-delta-pipeline/) — Node CLI、
+ manifest、多基线 bundle、发布选择与 GitHub Actions。
- [平台支持](/docs/zh-CN/platform-support/) — 架构与打包器行为。
- [架构](/docs/zh-CN/architecture/) — 执行路径与补丁兼容性。
- [可控制的原生操作](/docs/zh-CN/native-operations-v03/) — 资源限制、取消、进度与
原子输出约定。
-- [大文件演进路线](/docs/zh-CN/large-files-v04/) — 下一阶段的内存基线、真实进度和
+- [大文件演进路线](/docs/zh-CN/large-files-roadmap/) — 下一阶段的内存基线、真实进度和
流式能力可行性。
- [常见问题与排障](/docs/zh-CN/troubleshooting/) — 常见集成失败。
- [开发与验证](/docs/zh-CN/development/) — 本地构建、测试、WASM 与发布检查。
diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md
index d3dbd2f..2b11c99 100644
--- a/docs/zh-CN/api-reference.md
+++ b/docs/zh-CN/api-reference.md
@@ -1,7 +1,9 @@
# API 参考
-包从同一个入口导出两组平台专用 API。原生运行时使用绝对路径,Web 使用内存中的
-二进制值。
+包提供两组平台专用 API。已有共享代码可以导入根包并依赖其 React Native/browser
+条件解析;独立浏览器和桌面 WebView 消费者应使用明确的
+`react-native-bs-diff-patch/web` ESM 入口。原生运行时使用绝对路径,Web 使用内存中的
+二进制值。与平台无关的 manifest 工具位于 `react-native-bs-diff-patch/toolkit` ESM 入口。
```ts
import {
@@ -9,10 +11,13 @@ import {
patch,
startDiff,
startPatch,
+ startDiffBytes,
+ startPatchBytes,
diffBytes,
patchBytes,
inspectPatch,
verifyPatch,
+ classifyPatchError,
type BinaryInput,
type BinaryOperationOptions,
type PatchMetadata,
@@ -55,7 +60,7 @@ function patch(
- `patchFile`:已存在且兼容的补丁路径。
- 成功时返回 `0`,不会覆盖已有输出文件。
-## `startDiff` 与 `startPatch`
+## 原生 `startDiff` 与 `startPatch`
```ts
interface NativeOperationOptions {
@@ -109,6 +114,7 @@ interface BinaryOperationOptions {
signal?: AbortSignal;
maxInputBytes?: number;
maxOutputBytes?: number;
+ onProgress?: (event: BinaryOperationProgress) => void;
}
function diffBytes(
@@ -121,7 +127,9 @@ function diffBytes(
在 Web Worker 中生成补丁,仅 Web 可用。
- 接受 `ArrayBuffer`、任意 TypedArray、`DataView` 和 `Blob`。
-- 会复制输入,不会让调用方缓冲区失效。
+- 零字节二进制输入有效;原生路径 API 另行拒绝空路径字符串。
+- 保留调用方缓冲区;`Blob` 与 `File` 通过 WORKERFS 只读挂载,不会先在主线程
+ 生成完整副本。
- 返回包含 `ENDSLEY/BSDIFF43` 补丁的新 `Uint8Array`。
- 配置上限后,分别用 `maxInputBytes` 检查每个输入,并用 `maxOutputBytes`
检查生成补丁。
@@ -139,7 +147,8 @@ function patchBytes(
在 Web Worker 中应用兼容补丁,并返回还原后的字节。
- 进入 WebAssembly 核心前会校验补丁头。
-- 复制输入并返回新的 `Uint8Array`。
+- 空基线或空目标缓冲区在补丁格式允许时有效;损坏的补丁输入会被拒绝。
+- 保留输入并返回新的 `Uint8Array`。
- 不会修改 `oldData` 或 `patchData`。
- 当补丁头声明的输出超过 `maxOutputBytes` 时,会在分配输出前拒绝。
@@ -222,18 +231,48 @@ function verifyPatch(
其他请求。
- `maxInputBytes` 分别限制每个二进制输入,而不是输入之和。
- `maxOutputBytes` 限制生成补丁或还原输出。
+- `onProgress` 接收 C 核心产生的真实 `reading`、`processing` 和 `writing`
+ 检查点。
- 上限必须是非负安全整数;非法上限以 `EINVAL` 拒绝,超过上限以 `ERESOURCE`
拒绝。
原生端的二进制 API 接受 options 参数只是为了让共享封装保持源码兼容,随后仍会以
`EUNSUPPORTED` 拒绝。原生路径操作通过 `startDiff`、`startPatch` 获得同类控制。
+## Web job
+
+Web 端的 `startDiff()` 与 `startPatch()` 接收二进制输入,并返回
+`Promise` 结果的 job。`startDiffBytes()` 与 `startPatchBytes()` 是供
+跨平台封装明确使用的别名。
+
+```ts
+const job = startPatchBytes(oldFile, patchFile, {
+ maxOutputBytes: 128 * 1024 * 1024,
+});
+
+const unsubscribe = job.onProgress(({ phase, progress }) => {
+ renderProgress(phase, progress);
+});
+
+try {
+ const restored = await job.result;
+ // await job.cancel();
+} finally {
+ unsubscribe();
+}
+```
+
+取消 job 只会终止它自己的专用 Worker。`result` 会以 `EABORTED` 拒绝;`cancel()` 会在
+该 result 到达终态且 Worker/监听器清理完成后才 resolve。完成后再次调用 `cancel()` 是
+安全的,不会改变已确定的结果。进度来自 C/WASM 操作,不使用模拟百分比。
+
## 平台不可用时的行为
-所有函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes` 或
-`patchBytes`,以及在 Web 调用 `diff`、`patch`、`startDiff` 或 `startPatch`,
-都会以 `EUNSUPPORTED` 拒绝。`inspectPatch` 与 `verifyPatch` 在所有平台可用,但
-Android/iOS 必须传文件路径,Web 必须传二进制值。
+所有函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes`、
+`patchBytes`、`startDiffBytes` 或 `startPatchBytes` 会以 `EUNSUPPORTED` 拒绝。
+Web 端 `diff`、`patch` 不可用,但二进制 `startDiff` 与 `startPatch` 可用。
+`inspectPatch` 与 `verifyPatch` 在所有平台可用,但 Android/iOS 必须传文件路径,
+Web 必须传二进制值。
SSR 阶段导入 Web 入口不会启动 Worker;在没有浏览器 Worker 的环境调用二进制
API 会以 `EUNSUPPORTED` 拒绝。
@@ -248,7 +287,7 @@ type PatchError = Error & { code?: string };
| 错误码 | 含义 |
| ------------------- | ------------------------------------------ |
-| `EINVAL` | 输入为空、重复或类型无效。 |
+| `EINVAL` | 原生空路径/重复路径或输入与选项无效;零字节二进制输入有效。 |
| `ENOENT` | 原生端所需文件不存在。 |
| `EEXIST` | 原生端输出路径已经存在。 |
| `EUNSUPPORTED` | 当前平台不支持所选 API。 |
@@ -265,6 +304,10 @@ type PatchError = Error & { code?: string };
错误消息仅用于诊断,不是稳定的机器可读约定。恢复策略不同时应根据 `code` 分支。
+`classifyPatchError(error)` 会把平台专用错误统一归类为 `ABORTED`、`RESOURCE`、
+`INVALID_ARGUMENT`、`INVALID_PATCH`、`VERIFICATION`、`DESTINATION`、
+`UNSUPPORTED` 或 `RUNTIME`,同时保留原始 code 与消息。
+
## 并发与顺序
每个原生平台的 Promise 与 job 操作共用库内部串行队列。取消排队任务会阻止它进入
@@ -274,5 +317,9 @@ C 核心;运行中的任务会协作式观察取消。不带 signal 的 Web
## 补丁格式
-所有操作都读写 `ENDSLEY/BSDIFF43` 补丁。以 `BSDIFF40` 开头的其他 bsdiff
-变体不能互换。
+运行时操作只读写 `ENDSLEY/BSDIFF43`,不会自动接受其他 bsdiff 变体。已有
+`BSDIFF40` 可以通过 Node CLI 离线转换,并在发布前验证:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+```
diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md
index 417091d..20bcf78 100644
--- a/docs/zh-CN/architecture.md
+++ b/docs/zh-CN/architecture.md
@@ -16,7 +16,7 @@ React Native Web
-> 强类型公开 API
-> 共享或取消任务专用的模块 Web Worker
-> Emscripten MEMFS
- -> 由同一套 bsdiff + bzip2 C 源码编译的 WebAssembly
+ -> 由同一套 bsdiff + bzip2 C 源码编译的浏览器 WebAssembly
```
Worker 边界让高开销二进制计算离开 JavaScript / UI 线程,但不会消除算法成本。
@@ -40,23 +40,24 @@ MEMFS 临时文件。
| `16..23` | 该格式字节序下的有符号 64 位目标大小 |
| `24..` | bzip2 压缩的控制、差分和附加数据 |
-Web 适配器进入 C patch 函数前会校验头和签名。原生与 Web 使用同一份已检入的
-bsdiff 和 bzip2 源码,从而保持跨平台兼容。
+Web 适配器进入 C patch 函数前会校验头部 magic 与声明的目标大小。原生与 Web 使用同一份
+已检入的 bsdiff 和 bzip2 源码,从而保持跨平台兼容。
格式能标识补丁实现,但不标识预期基线或发布版本。分发补丁时,应用应在可信清单中
携带基线和目标摘要。
## WebAssembly 打包
-`scripts/build-web-wasm.sh` 使用 Emscripten 生成:
+`scripts/build-web-wasm.sh` 使用同一套 C 源码调用 Emscripten 两次,生成:
-- ES module 工厂;
-- 单文件内嵌 WebAssembly payload;
-- 可增长内存;
-- MEMFS 以及 `FS` / `ccall` 运行时方法;
-- 导出的 `bsDiffFile` 和 `bsPatchFile` 函数。
+- `web/bsdiffpatch.mjs`:带 NODEFS 的 Node 兼容 ES module 工厂,供 `/node` 入口和
+ CLI 使用;
+- `web/bsdiffpatch.browser.mjs`:不包含 Node 分支的浏览器/Worker ES module 工厂;
+- 两者都使用单文件内嵌 WebAssembly payload、可增长内存、MEMFS、`FS` / `ccall`
+ 运行时方法和补丁操作导出。
-生成的 `web/bsdiffpatch.mjs` 随 npm 包发布,消费者无需安装 Emscripten。
+两个生成模块都会随 npm 包发布。`/web` 资源图只会到达浏览器模块,`/node` 保留
+Node 模块;消费者无需安装 Emscripten。
## 兼容性验证
@@ -98,8 +99,9 @@ bsdiff 和 bzip2 源码,从而保持跨平台兼容。
## 内存模型
-原生操作会把旧文件与目标文件读入进程内存。Web 调用先复制输入再传给 Worker,
-之后从 MEMFS 复制结果,因此峰值内存可能达到输入或输出大小的数倍。在这组高度相似
+原生操作会把旧文件与目标文件读入进程内存。Web 的 ArrayBuffer 和 TypedArray 输入会
+复制到 Worker 的 MEMFS;Blob 与 File 使用只读 WORKERFS 挂载;结果再从 MEMFS 复制出,
+因此峰值内存可能达到输入或输出大小的数倍。在这组高度相似
的 50 MiB fixture 中,原生参考峰值约为输入的十九倍,主要来自后缀数组和同时存在的
文件缓冲区。
diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md
index fa8fef7..0892220 100644
--- a/docs/zh-CN/development.md
+++ b/docs/zh-CN/development.md
@@ -34,6 +34,7 @@ yarn test:web
yarn test:web:browser
yarn test:web:metro
yarn test:package
+yarn test:sdk
```
- `test:web` 检查 WebAssembly 往返和补丁 magic。
@@ -41,6 +42,8 @@ yarn test:package
- `test:web:metro` 证明 Metro 选择 `.web` 入口,而不是原生 TurboModule facade。
- `test:package` 将真实 tarball 安装到干净消费者,验证 browser、ESM、CommonJS、
TypeScript 与可选 peer 行为。
+- `test:sdk` 将准备好的 tarball 安装到隔离 Vite 消费者,验证明确的 `/web` 与
+ `/toolkit` ESM 入口、生产资源加载和真实字节往返。
## 原生健壮性与兼容性
@@ -82,7 +85,7 @@ BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native
大文件 profiling 使用 16、64、128 MiB fixture,可能消耗数 GiB 内存,因此不会作为
pull request 门禁。手动运行 `Native Core Benchmark` 时可以传入逗号分隔的尺寸列表,
-以获得共享 Runner 基线。解读结果时应遵循[大文件演进路线](/docs/zh-CN/large-files-v04/)
+以获得共享 Runner 基线。解读结果时应遵循[大文件演进路线](/docs/zh-CN/large-files-roadmap/)
中的范围和验收标准。
发布包 canary 会直接从 npm 安装,并有意使用当前 Vite 与 Expo 工具链;它们是定时
@@ -126,7 +129,10 @@ yarn test:web
yarn test:web:browser
```
-将重新生成的 `web/bsdiffpatch.mjs` 与 C 源码改动一起提交。
+将两个重新生成的模块与 C 源码改动一起提交。Node 兼容的
+`web/bsdiffpatch.mjs` 为 `/node` 和 CLI 保留 NODEFS;专用浏览器
+`web/bsdiffpatch.browser.mjs` 为 `/web` Worker 图排除 Node runtime 分支。不要为了
+隐藏打包器警告而互相替换两者。
## 原生验证
@@ -144,10 +150,13 @@ RN 0.86 新架构;React Native 0.82 及以上已不再提供旧架构运行时
## 发布检查清单
1. 执行核心、Web 和站点门禁。
-2. 运行 `yarn test:package`,并检查 `npm pack --dry-run --ignore-scripts`。
+2. 运行 `yarn test:package`、`yarn test:sdk`,并检查 `npm pack --dry-run`。
+ pack 命令会运行 `prepack` contract 检查;也可直接运行
+ `node scripts/check-package-contract.mjs`。
3. 确认公开文档与导出的 TypeScript 声明一致。
4. 确认中英文指南描述同一套公开行为。
-5. 运行 `yarn release` 创建版本、tag 和 GitHub Release;该命令不直接发布 npm。
+5. 准备好的 `package.json` 版本确定后,使用 `yarn release --no-increment` 创建
+ release commit、tag 和 GitHub Release;仅在维护者明确授权时运行。
6. GitHub Release 发布后会触发 `npm-publish.yml`。工作流校验 tag 与
`package.json` 版本一致,执行发布门禁,通过 npm Trusted Publishing 发布,
并验证 provenance 证明。
diff --git a/docs/zh-CN/getting-started.md b/docs/zh-CN/getting-started.md
index da55899..9fcbcd8 100644
--- a/docs/zh-CN/getting-started.md
+++ b/docs/zh-CN/getting-started.md
@@ -17,6 +17,9 @@ npx pod-install
React Native autolinking 会完成 Android 与 iOS 注册。安装后必须重新构建原生应用;
刷新 Metro 不会改变已经安装的应用二进制中包含的原生模块。
+如果使用 Vite 应用或桌面 WebView,请阅读[Web 与桌面 WebView SDK](./web-sdk.md)。
+它使用明确的 `/web` ESM 入口,不需要 React Native 或 Node sidecar。
+
## 按运行时选择 API
| 运行时 | 应使用 | 不应使用 |
diff --git a/docs/zh-CN/large-files-v04.md b/docs/zh-CN/large-files-roadmap.md
similarity index 65%
rename from docs/zh-CN/large-files-v04.md
rename to docs/zh-CN/large-files-roadmap.md
index d4e85bb..acf9926 100644
--- a/docs/zh-CN/large-files-v04.md
+++ b/docs/zh-CN/large-files-roadmap.md
@@ -1,4 +1,4 @@
-# 大文件演进路线(v0.4)
+# 大文件演进路线
本文定义项目如何评估更大输入、提供可信进度并研究流式处理,同时不削弱补丁兼容性。
它是一份可行性与测量计划,不承诺所有浏览器或移动设备都能处理某个固定尺寸。
@@ -6,11 +6,13 @@
## 当前约束
当前 diff 算法在构建和遍历后缀数组时,需要随机访问完整的旧、新输入。原生调用虽然
-接收文件路径,内存使用仍然与输入规模成比例。Web 实现还需要在 JavaScript、Worker
-与 WebAssembly 线性内存之间传递完整 buffer。
+接收文件路径,内存使用仍然与输入规模成比例。Web 的 TypedArray 输入仍需要传入
+Worker 与 WebAssembly 线性内存;`Blob`/`File` 通过 WORKERFS 避免主线程中的额外
+完整副本。Node 发布工具则通过 NODEFS 挂载宿主路径。
-应用补丁比生成 diff 的要求低,但当前 C 和 Web 边界仍会把完整操作状态放入内存。
-资源限制可以阻止无界工作,但不会让算法自动变成流式处理。
+应用补丁现在会以 64 KiB 分块读取旧文件和压缩补丁,并增量写入同目录临时输出。
+Web 仍返回完整 `Uint8Array`,因此浏览器边界会保留最终结果,但 C 核心不再同时分配
+完整旧文件与输出缓冲区。
## 测量矩阵
@@ -34,39 +36,39 @@ artifact,不作为 PR 阈值。
结果不代表更大尺寸或更低内存设备也获得支持。
首份 Apple M3 Pro / Node 22 记录已检入 `benchmarks/`。原生端以约 2.37 GiB 峰值 RSS
-完成 128 MiB;Web 端以约 2.09 GiB 峰值 RSS 完成 64 MiB,但 128 MiB 返回通用
-`EWEBASSEMBLY`。这一通用失败仍是错误分类缺口,因此项目目前不宣称 Web diff 支持
-128 MiB。
+完成 128 MiB;Web 端以约 2.09 GiB 峰值 RSS 完成 64 MiB,但 128 MiB 会耗尽
+WebAssembly 内存预算并被归类为 `ERESOURCE`;项目仍不宣称 Web diff 支持 128 MiB。
## 进度语义
-进度必须来自真实算法检查点,不能使用定时器或动画猜测完成比例。未来的跨平台操作
-可以沿用现有阶段:
+进度必须来自真实算法检查点,不能使用定时器或动画猜测完成比例。跨平台 job 使用
+以下阶段:
- `reading`:验证输入并加载核心需要的数据。
- `processing`:执行后缀数组/diff 工作或重建补丁。
- `writing`:原生端持久化并原子提交输出;Web 端在结果 buffer 可以传输时完成此阶段。
-原生 job 已暴露这些阶段。Web 对齐需要从插桩后的 C/WebAssembly 边界发出 Worker
-消息。在这些检查点出现之前,Web 只应报告开始、取消和完成,不能提供虚假百分比。
-公开 callback 保持可选,未传入时不得改变结果或错误行为。
+原生与 Web job 都已暴露这些阶段。Web 进度从插桩后的 C 检查点经 WebAssembly 和
+Worker 消息传递,不使用定时器或模拟百分比。公开 callback 保持可选,未传入时不会
+改变结果或错误行为。
## 流式处理可行性
真正的流式 diff 不是当前 BSDiff 算法的兼容优化:后缀数组构建和匹配需要全局随机
访问两份输入。要实现它,必须选择另一种算法或新补丁格式,并明确兼容与迁移策略。
-补丁应用更适合增量处理。原型可以按有界 chunk 读取旧文件和压缩后的
-control/diff/extra 流,写入临时目标,同时保留 `ENDSLEY/BSDIFF43` 约定。浏览器应先
-支持 `Blob`/`File` 和内部有界 reader;可写文件句柄继续作为渐进增强能力。
+补丁应用已经使用有界 C 实现:按块读取旧文件与压缩后的 control/diff/extra 流,
+写入临时目标,同时保留 `ENDSLEY/BSDIFF43` 约定。浏览器 `Blob`/`File` 使用只读
+WORKERFS 挂载;公开 API 仍返回完整 buffer,因此直接写浏览器文件句柄继续作为渐进
+增强能力。
## 实施顺序
1. 保持原生与 Web 的 16/64/128 MiB 耗时和峰值内存基线。
-2. 为核心检查点插桩,增加真实的 Web 进度事件,不改变现有 `diff`、`patch` 或
- `startPatch` 约定。
-3. 验证文件后备、增量应用补丁的原型,并证明取消、资源限制、临时清理和逐字节兼容。
-4. 根据实测收益决定是否值得新增公开 API;流式 diff 算法或新补丁格式另立提案。
+2. 在可比设备上测量已经完成的 C/WASM 进度与有界 patch 路径,比较改造前后的
+ 峰值内存。
+3. 评估直接写入浏览器文件句柄,同时保持取消、资源限制、清理与逐字节兼容。
+4. 流式 diff 算法或新补丁格式继续作为独立提案。
所有生产 API 都必须保持确定性输出验证,在大额分配前尽可能拒绝超限尺寸,在取消和
失败时清理资源,并通过 Android API 24、iOS Simulator、浏览器与跨平台 golden patch
diff --git a/docs/zh-CN/native-operations-v03.md b/docs/zh-CN/native-operations-v03.md
index 1b5451c..018f560 100644
--- a/docs/zh-CN/native-operations-v03.md
+++ b/docs/zh-CN/native-operations-v03.md
@@ -60,9 +60,10 @@ job 操作会独占创建同目录临时文件,完成写入、刷新与校验
## 平台差异
-job API 仅用于 Android 与 iOS。React Native Web 应使用二进制 `diffBytes`、
-`patchBytes` API,并通过 `AbortSignal` 与字节限制控制任务;Web 调用 `startDiff`
-或 `startPatch` 会以 `EUNSUPPORTED` 拒绝。
+Android 与 iOS job 接收文件路径并返回 `0`;React Native Web job 接收二进制输入
+并返回 `Uint8Array`,也可以使用明确的 `startDiffBytes`、`startPatchBytes` 别名。
+Web 取消会终止当前 job 的专用 Worker 并以 `EABORTED` 拒绝,进度来自同一套 C 核心
+检查点。
补丁格式仍是 `ENDSLEY/BSDIFF43`。0.3 改变的是操作控制,不是补丁兼容性。
diff --git a/docs/zh-CN/platform-support.md b/docs/zh-CN/platform-support.md
index f29cef0..834eb16 100644
--- a/docs/zh-CN/platform-support.md
+++ b/docs/zh-CN/platform-support.md
@@ -48,6 +48,11 @@ TurboModule 实例。
## React Native Web
+独立浏览器和桌面 WebView 应用应导入明确的
+`react-native-bs-diff-patch/web` ESM 入口。它的 Worker 资源图使用不含 Node 分支的
+`web/bsdiffpatch.browser.mjs`;`/toolkit` 入口同样仅支持 ESM。根包的 `browser` 条件
+继续供已有 React Native Web 消费者使用。资源与 CSP 要求见[Web 与桌面 WebView SDK](./web-sdk.md)。
+
包提供两种 Web 入口机制:
- `browser` 字段让标准浏览器感知型打包器选择 `web/index.mjs`。
@@ -66,13 +71,13 @@ Webpack 与 Vite 能识别标准的
`new Worker(new URL(..., import.meta.url), { type: 'module' })` 模式。Metro Web
配置需要在 Web serializer 中保留模块 Worker URL。
-Web 入口面向浏览器,不是 Node.js 文件系统适配器;它不会在 Node.js 中提供原生
-文件路径 API。
-原生 job 函数仍会导出以保持统一导入形式,但在 Web 上以 `EUNSUPPORTED` 拒绝。
+Web 入口面向浏览器,不会在浏览器中提供原生文件路径 API。Web 的 `startDiff`、
+`startPatch` 使用二进制输入;单独的 `./node` 包入口提供发布端文件系统操作。
未传 `AbortSignal` 的调用共用模块 Worker 与已初始化的 WebAssembly 模块;带
signal 的调用使用专用 Worker,保证取消只影响当前任务。两种路径都会在各自 Worker
内串行执行,但调用方仍应设置应用级内存预算。
+`Blob` 与 `File` 会通过只读 WORKERFS 挂载,避免在主线程生成完整副本。
`inspectPatch` 不会启动 Worker。`verifyPatch` 先验证元数据,再复用 `patchBytes`
的 Worker 路径,并在与预期输入比较后丢弃还原缓冲区。
diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md
index cd66fc8..e2dcfe8 100644
--- a/docs/zh-CN/troubleshooting.md
+++ b/docs/zh-CN/troubleshooting.md
@@ -62,8 +62,8 @@
确认打包器输出了模块 Worker 资源,并且服务器将 `.mjs` 作为 JavaScript 提供。
严格 CSP 需要允许同源 Worker 和 WebAssembly 执行。
-在浏览器网络面板中确认 `worker.mjs`、`operations.mjs` 和 `bsdiffpatch.mjs`
-返回成功状态,而不是应用 HTML fallback。
+在浏览器网络面板中确认 `worker.browser.mjs`、`operations.browser.mjs` 和
+`bsdiffpatch.browser.mjs` 返回成功状态,而不是应用 HTML fallback。
## `EPATCH`、`EWEBASSEMBLY` 或补丁损坏
diff --git a/docs/zh-CN/verified-delta-pipeline.md b/docs/zh-CN/verified-delta-pipeline.md
new file mode 100644
index 0000000..fa4be48
--- /dev/null
+++ b/docs/zh-CN/verified-delta-pipeline.md
@@ -0,0 +1,130 @@
+# 可验证增量发布工具链
+
+这个包既可以作为客户端运行时,也可以作为发布端 Node 工具,或者同时承担两种角色。
+共享的 manifest 与 bundle schema 将补丁生成、CDN 选择和验证还原连接起来,但不会
+接管传输系统或私钥。
+
+## Node CLI
+
+可以在发布工作区安装包,也可以直接通过 `npx` 运行:
+
+```sh
+npx react-native-bs-diff-patch diff old.bin new.bin -o update.patch
+npx react-native-bs-diff-patch inspect update.patch --json
+npx react-native-bs-diff-patch verify old.bin update.patch new.bin
+npx react-native-bs-diff-patch manifest \
+ old.bin update.patch new.bin -o patch-manifest.json
+```
+
+CLI 不会覆盖已有输出。Node 复用 Web 发布的同一份 WebAssembly 核心,并通过
+NODEFS 直接挂载宿主路径,避免在 JavaScript 中再产生一份完整文件副本。diff
+生成仍会在 C/WASM 核心内为完整输入建立索引;patch 应用与验证走有界流式文件路径。
+
+## 可验证补丁 manifest
+
+环境无关的 toolkit 入口提供 `createPatchManifest()` 与
+`validatePatchManifest()`:
+
+```ts
+import {
+ canonicalJson,
+ createPatchManifest,
+ signingPayload,
+} from 'react-native-bs-diff-patch/toolkit';
+
+const manifest = createPatchManifest({
+ baseline: { bytes: 1000, sha256: baselineSha256 },
+ patch: { bytes: 120, sha256: patchSha256, url: 'update.patch' },
+ target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' },
+});
+
+const bytesToSign = new TextEncoder().encode(signingPayload(manifest));
+const canonical = canonicalJson(manifest);
+```
+
+库负责 canonical JSON、SHA-256 描述校验和 detached-signature 元数据,但不会
+加载、保存或管理私钥。请通过现有发布签名系统认证 canonical manifest。
+
+## Node 验证还原
+
+Node 入口会先验证基线与补丁,应用后再验证目标;只有全部一致才保留输出:
+
+```ts
+import {
+ createFilePatchManifest,
+ restoreVerified,
+} from 'react-native-bs-diff-patch/node';
+
+const manifest = await createFilePatchManifest(
+ 'old.bin',
+ 'update.patch',
+ 'new.bin'
+);
+
+await restoreVerified('old.bin', 'update.patch', 'restored.bin', manifest);
+```
+
+基线、补丁或目标不匹配时会以验证错误拒绝,并删除请求的输出。
+
+## 多基线 bundle
+
+从基线目录内的每个普通文件生成到同一目标的发布计划:
+
+```sh
+npx react-native-bs-diff-patch bundle \
+ --from releases/ \
+ --to dist/app.bin \
+ --out dist/update-bundle \
+ --max-ratio 0.85 \
+ --release-id v1.5.0
+```
+
+输出内容包括:
+
+- 完整目标文件回退;
+- 每个高收益基线对应的 `ENDSLEY/BSDIFF43` 补丁;
+- 面向人工和 CDN 的 `bundle-manifest.json`;
+- 面向签名的 `bundle-manifest.canonical.json`;
+- 显示补丁或完整文件选择结果的决策报告。
+
+运行时通过 `selectPatch()` 匹配可信基线 SHA-256,并可附加补丁字节数或比例预算。
+基线不存在或补丁不划算时会明确选择完整文件。
+
+## GitHub Action
+
+仓库提供了一个零依赖的单基线 Action:
+
+```yaml
+- uses: JimmyDaddy/react-native-bs-diff-patch@v0.5.0
+ id: delta
+ with:
+ old-file: releases/v1.bin
+ new-file: dist/app.bin
+ patch-file: dist/update.patch
+ manifest-file: dist/patch-manifest.json
+ max-patch-ratio: '0.85'
+
+- run: echo "strategy=${{ steps.delta.outputs.strategy }}"
+```
+
+多基线发布使用 CLI 的 `bundle` 命令。Action 会输出补丁大小、目标大小、比例、节省量
+以及最终 `patch` 或 `full` 策略。
+
+## BSDIFF40 迁移
+
+运行时继续只接受 `ENDSLEY/BSDIFF43`。已有 `BSDIFF40` 可以在不读取基线文件的
+情况下离线转换:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+npx react-native-bs-diff-patch inspect compatible.patch
+```
+
+转换器会校验 BSDIFF40 的三个压缩块,并改写为交错的 BSDIFF43 数据流;损坏输入或
+已有输出都会被拒绝。发布前仍应使用已知基线和目标验证转换后的补丁。
+
+## 浏览器 Release Planner
+
+[发布规划器](https://bs-dff-patch.corerobin.com/zh-CN/planner/) 可以完全在浏览器
+本地生成多基线矩阵与相同的 bundle manifest,适合评估和调试。生产产物应通过 CLI
+在 CI 中复现;规划器不会上传用户选择的文件。
diff --git a/docs/zh-CN/web-sdk.md b/docs/zh-CN/web-sdk.md
new file mode 100644
index 0000000..7725578
--- /dev/null
+++ b/docs/zh-CN/web-sdk.md
@@ -0,0 +1,281 @@
+# Web 与桌面 WebView SDK
+
+本指南面向浏览器、Tauri 2 WebView 或其他 TypeScript 应用:在不安装
+React Native、也不启动 Node sidecar 的情况下使用二进制补丁引擎。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
+打包器应保留
+`new Worker(new URL('./worker.browser.mjs', import.meta.url), { type: 'module' })` 关系。
+
+## 最小 Vite 或 Tauri 往返
+
+在拥有 WebView 的应用中安装包:
+
+```sh
+# 0.5.0 Web SDK 的主安装路径:
+npm install react-native-bs-diff-patch@^0.5.0
+```
+
+发布前验证本地准备的包时,可以将其替换为 tarball:
+
+```sh
+npm install ./react-native-bs-diff-patch-0.5.0.tgz
+```
+
+registry 中的 0.4.x 包尚未包含 `/web` 和 `/toolkit` 子路径。发布前验证这些入口时,不要
+使用未带版本的 registry 安装命令作为验证依据。
+
+下面的代码只导入公开 Web 入口,执行真实的逐字节往返。它不读取路径,也不需要
+React、React Native、Node 或服务器接口:
+
+```ts
+import {
+ diffBytes,
+ inspectPatch,
+ patchBytes,
+ verifyPatch,
+} from 'react-native-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 controller = new AbortController();
+const patch = await diffBytes(baseline, target, {
+ signal: controller.signal,
+ 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, {
+ maxInputBytes: 32 * 1024 * 1024,
+ maxOutputBytes: 32 * 1024 * 1024,
+});
+
+if (!verification.verified || restored.length !== target.length) {
+ throw new Error('restored bytes do not match the target');
+}
+```
+
+相同函数也接受 `ArrayBuffer`、任意 `ArrayBufferView`(包括 `DataView`)或
+`Blob`/`File`。浏览器选择的文件可以直接传入:
+
+```ts
+const patch = await diffBytes(oldFile, newFile);
+const patchBlob = new Blob([patch.slice().buffer as ArrayBuffer]);
+const restored = await patchBytes(oldFile, patchBlob);
+```
+
+结果是新的 `Uint8Array`。TypedArray 的 offset 和长度会被保留,调用结束后调用方的
+输入缓冲区仍可使用。Worker 不会接管调用方缓冲区的所有权。`Blob` 与 `File` 会在
+Worker 内通过只读 WORKERFS 挂载供 C 核心读取,开始操作前不会在主线程额外生成完整副本。
+保存或传输补丁时应保持二进制形式;通过 UTF-8 转换会破坏任意补丁字节。
+
+## Job、取消与清理
+
+需要界面进度、明确的取消操作或独立任务生命周期时,使用二进制 job:
+
+```ts
+import { startPatchBytes } from 'react-native-bs-diff-patch/web';
+
+const job = startPatchBytes(oldFile, patchFile, {
+ maxInputBytes: 64 * 1024 * 1024,
+ maxOutputBytes: 128 * 1024 * 1024,
+ onProgress: renderProgress,
+});
+const unsubscribe = job.onProgress(renderProgress);
+
+cancelButton.onclick = () => void job.cancel();
+try {
+ const restored = await job.result;
+ consume(restored);
+} catch (error) {
+ if ((error as { code?: string }).code !== 'EABORTED') throw error;
+} finally {
+ unsubscribe();
+}
+```
+
+Web 入口的 `startDiff`、`startPatch`、`startDiffBytes` 与 `startPatchBytes` 都是二进制
+job API。`result` 返回新的 `Uint8Array`,`cancel()` 只影响当前操作。取消会终止当前
+专用 Worker,并以 `EABORTED` 拒绝;它不是用 Promise 超时冒充中断,也不会打断其他 job。
+`cancel()` 只有在 `result` 到达终态且 job 清理完成后才会 resolve;`result` 本身会以
+`EABORTED` 拒绝。重复取消是安全的;已完成 job 再次取消不会改变其已确定的结果。取消或
+失败不会返回半成品结果。
+
+Worker 操作结束时,库会删除由操作拥有的 MEMFS 文件和监听器。共享 Worker 没有公开的
+`dispose()`:不带 signal 的调用复用模块 Worker 和缓存的 WASM 模块,带 signal 的调用
+使用专用 Worker,并在结束后终止。应用仍需在不再使用时撤销自己的
+`URL.createObjectURL()` URL,并释放返回缓冲区的引用。
+
+不带 signal 的调用共享一个串行 Worker 队列。带 signal 的调用(包括 `start*` job
+封装)使用专用 Worker,从而隔离取消。SDK 不设置应用级总内存或并发预算;大任务开始
+前应由应用限制并发数。
+
+## 限制与内存行为
+
+`maxInputBytes` 与 `maxOutputBytes` 是每个操作可选的保护边界:
+
+- `maxInputBytes` 分别作用于每个输入,不是总内存或输入之和的上限。
+- `maxOutputBytes` 作用于生成的补丁或还原输出。应用补丁时,会在解压和分配输出前
+ 检查补丁声明的目标大小,并再次检查实际结果。
+- 限制必须是非负安全整数。非法值以 `EINVAL` 拒绝;超过字节限制以 `ERESOURCE`
+ 拒绝。
+
+算法和 WebAssembly 适配器的峰值内存可能是输入或输出的数倍。当前生成的浏览器 WASM
+构建保留 Emscripten 配置的 2 GiB 最大线性内存设置。这是构建设置,不是 WebAssembly
+标准规定的限制,也不是所有引擎的统一硬上限;不保证所有浏览器、Tauri WebView 或设备
+都能分配这么多,宿主可能因为自身的 WebAssembly 线性内存或标签页预算更早失败。可识别的分配和内存访问失败归类为
+`ERESOURCE`,其他 Worker 或 WebAssembly 失败使用 `EWEBASSEMBLY`。应把目标 WebView
+实测的上限作为环境约束,记录已经验证的输入尺寸。不要把 `maxInputBytes` 宣传成总进程
+内存保证。工具链或生成的 WASM 构建变化后应重新确认该上限。
+
+## 错误与信任边界
+
+错误是带有尽力分类字符串 `code` 的普通 `Error`。需要分支时使用 code,不要依赖错误
+消息文本:
+
+| Code | 含义 |
+| --- | --- |
+| `EINVAL` | 类型格式错误、不支持的输入类型或非法选项(原生空路径或重复路径也无效;零字节二进制输入有效) |
+| `EUNSUPPORTED` | Web Worker 或选择的平台 API 不可用 |
+| `EABORTED` | Web signal 或 job 被取消 |
+| `ERESOURCE` | 超过输入/输出边界,或可识别的运行时分配限制 |
+| `EPATCH` | 补丁头或补丁 payload 损坏或不支持 |
+| `EWEBASSEMBLY` | Worker 启动、资源加载或未分类的 WASM 失败 |
+
+`inspectPatch()` 是低成本的头部检查。它从二进制输入最多读取 24 字节头,不应用也不
+认证补丁。`/toolkit` 的 `inspectPatchHeader()` 对调用方提供的 `Uint8Array` 具有相同
+的只检查头部目的。`valid: true` 只表示 magic 和声明的目标大小头字段在结构上可接受;
+不表示压缩 payload 完整、不表示基线正确,也不表示签名有效。替换应用数据前应结合
+`verifyPatch()` 与可信摘要/签名策略。
+
+运行时生成和应用支持 `ENDSLEY/BSDIFF43`。`BSDIFF40` 输入会在头部检查时识别为
+`format: 'BSDIFF40'`、`valid: false`、`issue: 'LEGACY_FORMAT'`,不会静默应用。已有
+Node 转换器仍可用于离线迁移:
+
+```sh
+npx react-native-bs-diff-patch convert legacy.patch -o compatible.patch
+```
+
+发布前请用准确的基线和目标验证转换后的补丁。补丁格式本身不会标识预期基线。
+
+## Toolkit manifest 与候选选择
+
+Toolkit 不访问文件系统、网络或私钥,只校验和规范化调用方传入的数据:
+
+```ts
+import {
+ canonicalJson,
+ createPatchBundle,
+ createPatchManifest,
+ selectPatch,
+ signingPayload,
+} from 'react-native-bs-diff-patch/toolkit';
+
+const manifest = createPatchManifest({
+ baseline: { bytes: 1000, sha256: baselineSha256 },
+ patch: { bytes: 120, sha256: patchSha256, url: 'release.patch' },
+ target: { bytes: 1100, sha256: targetSha256, url: 'app.bin' },
+});
+const bytesToSign = new TextEncoder().encode(signingPayload(manifest));
+const canonical = canonicalJson(manifest);
+```
+
+`validatePatchManifest()` 和 `validatePatchBundle()` 检查结构、字节数、SHA-256 形状、
+格式与 bundle 目标一致性。但它们不会读取 URL、下载 artifact、计算哈希、验证签名,也
+不会证明字节与描述匹配。未知字段会从规范化返回值中丢弃;应用自有字段应放在自己的
+外层 envelope 中,或明确使用 schema 支持的 `releaseId`/`signature`。
+
+`canonicalJson()` 会排序对象键并省略对象属性中的 `undefined`。`signingPayload()` 返回
+移除 detached signature 元数据后的 canonical JSON。二者都不会签名:canonical JSON 与
+signing payload 是外部密码学签名系统的输入,不是数字签名本身。
+
+`selectPatch()` 会先验证 bundle 和选择选项,然后:
+
+1. 将传入的 64 字符 baseline SHA-256 转小写,寻找 digest 完全相同的第一个候选。
+2. 没有候选时返回完整 artifact,原因是 `BASELINE_NOT_FOUND`。
+3. 超过 `maxPatchBytes` 时返回完整 artifact,原因是 `PATCH_BYTES_EXCEEDED`。
+4. 当 `candidate.patch.bytes / max(1, full.bytes)` 大于 `maxPatchRatio` 时返回完整
+ artifact,原因是 `PATCH_RATIO_EXCEEDED`。
+5. 其他情况返回该第一个匹配候选,原因是 `BASELINE_MATCH`,策略为 `patch`。
+
+该工具不会搜索最小补丁,也不会执行还原。应用必须先认证 manifest,再下载并校验所选
+artifact 的摘要,然后按需要运行 `patchBytes()` 和 `verifyPatch()`。
+
+## Vite 与 Tauri 打包检查清单
+
+在应用源码中使用包名,让打包器依据 exports 解析。生产构建必须包含 `/web` 入口的
+模块 Worker 与浏览器 WASM 资源图。当前采用单文件 WASM 构建时,二进制 payload 嵌入
+生成的浏览器模块;消费者不需要复制独立 `.wasm` 文件,也不需要安装 Emscripten。
+
+发布前应检查生产 bundle,并在断网条件下从构建产物运行,至少确认:
+
+- `react-native-bs-diff-patch/web` 与 `/toolkit` 从安装的 tarball 解析,不使用 workspace
+ link、源码 alias 或私有深路径;
+- Worker URL 解析到随包发布的同源资源;
+- 浏览器 WASM 从包资源图加载,而不是 CDN;
+- 断网后生产应用仍能生成、应用和验证补丁;
+- 目标 WebView 的 CSP 允许 Worker 与 WebAssembly 执行;
+- Rust 或其他桌面层负责文件授权和持久化,JavaScript 只向 SDK 传递字节。
+
+SDK 所需的最小 CSP 增量为:
+
+```text
+script-src 'self' 'wasm-unsafe-eval';
+worker-src 'self';
+```
+
+请将这些 source 合并到应用现有策略中。不要添加普通 `unsafe-eval`,不要从 CDN 加载
+引擎,也不要以放宽策略作为 fallback。本指南只记录所需策略;真实 Tauri WebView 的
+接受情况仍由下游应用验收。
+
+## 验证命令
+
+仓库内相关本地检查如下:
+
+```sh
+yarn test:web
+yarn test:web:browser
+yarn test:web:metro
+yarn test:toolkit
+yarn test:sdk
+yarn typecheck
+yarn site:build
+yarn site:test
+```
+
+其中 `yarn test:sdk` 会把准备好的 tarball 安装到隔离消费者中,检查公开 `/web`、
+`/toolkit` ESM 入口、生产 Vite 资源加载和字节往返。Registry smoke 属于独立的发布后
+检查;这些检查不等于 Tauri 真机验收,也不宣称 registry smoke 已通过。
diff --git a/node/index.d.ts b/node/index.d.ts
new file mode 100644
index 0000000..7dbe78d
--- /dev/null
+++ b/node/index.d.ts
@@ -0,0 +1,76 @@
+import type { PatchArtifact, PatchManifest } from '../toolkit/index.js';
+
+export interface NodeOperationOptions {
+ maxInputBytes?: number;
+ maxOutputBytes?: number;
+ onProgress?: (event: {
+ operation: 'diff' | 'patch';
+ phase: 'reading' | 'processing' | 'writing';
+ progress: number;
+ }) => void;
+}
+
+export interface NodeOperationResult {
+ bytes: number;
+ outputPath: string;
+ sha256: string;
+}
+
+export function sha256Bytes(data: ArrayBufferView): string;
+export function sha256File(filePath: string): Promise;
+export function describeFile(
+ filePath: string,
+ options?: { name?: string; url?: string }
+): Promise;
+export function inspectPatchFile(
+ patchPath: string
+): Promise;
+export function diffFiles(
+ oldPath: string,
+ newPath: string,
+ outputPath: string,
+ options?: NodeOperationOptions
+): Promise;
+export function patchFiles(
+ oldPath: string,
+ patchPath: string,
+ outputPath: string,
+ options?: NodeOperationOptions
+): Promise;
+export function verifyPatchFiles(
+ oldPath: string,
+ patchPath: string,
+ expectedPath: string
+): Promise<{
+ expectedBytes: number;
+ expectedSha256: string;
+ restoredBytes: number;
+ restoredSha256: string;
+ verified: boolean;
+}>;
+export function createFilePatchManifest(
+ oldPath: string,
+ patchPath: string,
+ targetPath: string,
+ options?: {
+ baselineName?: string;
+ baselineUrl?: string;
+ patchName?: string;
+ patchUrl?: string;
+ targetName?: string;
+ targetUrl?: string;
+ releaseId?: string;
+ signature?: PatchManifest['signature'];
+ }
+): Promise;
+export function restoreVerified(
+ oldPath: string,
+ patchPath: string,
+ outputPath: string,
+ manifest: PatchManifest,
+ options?: NodeOperationOptions
+): Promise;
+export function convertBsdiff40File(
+ inputPath: string,
+ outputPath: string
+): Promise;
diff --git a/node/index.mjs b/node/index.mjs
new file mode 100644
index 0000000..b3f28e9
--- /dev/null
+++ b/node/index.mjs
@@ -0,0 +1,473 @@
+import { createHash } from 'node:crypto';
+import { access, link, mkdir, mkdtemp, open, rm, stat } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+import createBsDiffPatchModule from '../web/bsdiffpatch.mjs';
+import {
+ createPatchManifest,
+ inspectPatchHeader,
+ validatePatchManifest,
+} from '../toolkit/index.mjs';
+
+let wasmOperationQueue = Promise.resolve();
+let nodeModulePromise;
+const PHASE_NAMES = ['reading', 'processing', 'writing'];
+
+function runSerialized(operation) {
+ const result = wasmOperationQueue.then(operation, operation);
+ wasmOperationQueue = result.catch(() => {});
+ return result;
+}
+
+function createNodeError(code, message) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function getNodeModule() {
+ if (!nodeModulePromise) {
+ const pendingModule = createBsDiffPatchModule({
+ print: () => {},
+ printErr: () => {},
+ });
+ nodeModulePromise = pendingModule;
+ pendingModule.catch(() => {
+ if (nodeModulePromise === pendingModule) {
+ nodeModulePromise = undefined;
+ }
+ });
+ }
+ return nodeModulePromise;
+}
+
+function validateLimit(value, fieldName) {
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
+ throw createNodeError(
+ 'EINVAL',
+ `${fieldName} must be a non-negative safe integer`
+ );
+ }
+}
+
+async function assertRegularFile(filePath) {
+ const fileStat = await stat(filePath);
+ if (!fileStat.isFile()) {
+ throw createNodeError('EINVAL', `not a regular file: ${filePath}`);
+ }
+ return fileStat;
+}
+
+async function enforceInputLimits(filePaths, maximumBytes) {
+ validateLimit(maximumBytes, 'maxInputBytes');
+ const stats = await Promise.all(filePaths.map(assertRegularFile));
+ if (maximumBytes !== undefined) {
+ const oversizedIndex = stats.findIndex(
+ (fileStat) => fileStat.size > maximumBytes
+ );
+ if (oversizedIndex >= 0) {
+ throw createNodeError(
+ 'ERESOURCE',
+ `${filePaths[oversizedIndex]} is ${stats[oversizedIndex].size} bytes and exceeds the ${maximumBytes} byte limit`
+ );
+ }
+ }
+ return stats;
+}
+
+function operationResultError(result) {
+ const codes = new Map([
+ [-2, 'ERESOURCE'],
+ [-3, 'ERESOURCE'],
+ [-4, 'EABORTED'],
+ [-5, 'EDESTEXISTS'],
+ ]);
+ return createNodeError(
+ codes.get(result) || 'EWEBASSEMBLY',
+ `native function returned ${result}`
+ );
+}
+
+function ensureVirtualDirectory(module, mountPath) {
+ if (!module.FS.analyzePath(mountPath).exists) {
+ module.FS.mkdir(mountPath);
+ }
+}
+
+function mountHostFile(module, mountName, hostPath) {
+ const absolutePath = path.resolve(hostPath);
+ const mountPath = `/node-${mountName}`;
+ ensureVirtualDirectory(module, mountPath);
+ module.FS.mount(
+ module.NODEFS,
+ { root: path.dirname(absolutePath) },
+ mountPath
+ );
+ return {
+ mountPath,
+ virtualPath: `${mountPath}/${path.basename(absolutePath)}`,
+ };
+}
+
+async function runHostFileOperation(
+ operation,
+ inputPaths,
+ outputPath,
+ options = {}
+) {
+ const module = await getNodeModule();
+ if (!module.NODEFS) {
+ throw createNodeError(
+ 'EUNSUPPORTED',
+ 'the WebAssembly bundle does not include NODEFS'
+ );
+ }
+
+ const mounts = [];
+ try {
+ const virtualInputs = inputPaths.map((inputPath, index) => {
+ const mount = mountHostFile(module, `input-${index}`, inputPath);
+ mounts.push(mount);
+ return mount.virtualPath;
+ });
+ const outputMount = mountHostFile(module, 'output', outputPath);
+ mounts.push(outputMount);
+ module.onProgress = (phase, progress) => {
+ options.onProgress?.({
+ operation,
+ phase: PHASE_NAMES[phase] || 'processing',
+ progress: Math.max(0, Math.min(1, progress)),
+ });
+ };
+
+ const functionName =
+ operation === 'diff'
+ ? 'bsDiffFileWithProgressAndLimits'
+ : 'bsPatchFileWithProgressAndLimits';
+ const fileArgs =
+ operation === 'diff'
+ ? [...virtualInputs, outputMount.virtualPath]
+ : [virtualInputs[0], outputMount.virtualPath, virtualInputs[1]];
+ const args = [
+ ...fileArgs,
+ options.maxInputBytes ?? -1,
+ options.maxOutputBytes ?? -1,
+ ];
+ const result = module.ccall(
+ functionName,
+ 'number',
+ ['string', 'string', 'string', 'number', 'number'],
+ args
+ );
+ if (result !== 0) {
+ throw operationResultError(result);
+ }
+ } finally {
+ module.onProgress = undefined;
+ for (const mount of mounts.reverse()) {
+ try {
+ module.FS.unmount(mount.mountPath);
+ } catch {
+ // A failed native operation may already have invalidated a mount.
+ }
+ }
+ }
+}
+
+async function runHostConverter(inputPath, outputPath) {
+ const module = await getNodeModule();
+ if (!module.NODEFS) {
+ throw createNodeError(
+ 'EUNSUPPORTED',
+ 'the WebAssembly bundle does not include NODEFS'
+ );
+ }
+ const mounts = [];
+ try {
+ const inputMount = mountHostFile(module, 'convert-input', inputPath);
+ const outputMount = mountHostFile(module, 'convert-output', outputPath);
+ mounts.push(inputMount, outputMount);
+ const result = module.ccall(
+ 'bsConvertBsdiff40File',
+ 'number',
+ ['string', 'string'],
+ [inputMount.virtualPath, outputMount.virtualPath]
+ );
+ if (result !== 0) {
+ throw createNodeError(
+ 'ELEGACYFORMAT',
+ `BSDIFF40 converter returned ${result}`
+ );
+ }
+ } finally {
+ for (const mount of mounts.reverse()) {
+ try {
+ module.FS.unmount(mount.mountPath);
+ } catch {
+ // Preserve the converter error if cleanup also fails.
+ }
+ }
+ }
+}
+
+async function assertOutputAvailable(outputPath) {
+ try {
+ await access(outputPath);
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ return;
+ }
+ throw error;
+ }
+ throw createNodeError('EDESTEXISTS', `output already exists: ${outputPath}`);
+}
+
+export function sha256Bytes(data) {
+ return createHash('sha256').update(data).digest('hex');
+}
+
+export async function sha256File(filePath) {
+ const file = await open(filePath, 'r');
+ const hash = createHash('sha256');
+ try {
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
+ hash.update(chunk);
+ }
+ } finally {
+ await file.close();
+ }
+ return hash.digest('hex');
+}
+
+export async function describeFile(filePath, options = {}) {
+ const fileStat = await assertRegularFile(filePath);
+ return {
+ bytes: fileStat.size,
+ sha256: await sha256File(filePath),
+ ...(options.name === undefined ? {} : { name: options.name }),
+ ...(options.url === undefined ? {} : { url: options.url }),
+ };
+}
+
+export async function inspectPatchFile(patchPath) {
+ const file = await open(patchPath, 'r');
+ try {
+ const fileStat = await file.stat();
+ const header = new Uint8Array(Math.min(24, fileStat.size));
+ if (header.byteLength > 0) {
+ await file.read(header, 0, header.byteLength, 0);
+ }
+ return inspectPatchHeader(header, fileStat.size);
+ } finally {
+ await file.close();
+ }
+}
+
+export async function diffFiles(oldPath, newPath, outputPath, options = {}) {
+ validateLimit(options.maxOutputBytes, 'maxOutputBytes');
+ if (options.maxOutputBytes === 0) {
+ throw createNodeError(
+ 'ERESOURCE',
+ 'a BSDIFF43 patch cannot fit within a zero-byte output limit'
+ );
+ }
+ await assertOutputAvailable(outputPath);
+ await enforceInputLimits([oldPath, newPath], options.maxInputBytes);
+ await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
+ await runSerialized(() =>
+ runHostFileOperation('diff', [oldPath, newPath], outputPath, options)
+ );
+ const output = await describeFile(outputPath);
+ return {
+ bytes: output.bytes,
+ outputPath,
+ sha256: output.sha256,
+ };
+}
+
+export async function patchFiles(oldPath, patchPath, outputPath, options = {}) {
+ validateLimit(options.maxOutputBytes, 'maxOutputBytes');
+ await assertOutputAvailable(outputPath);
+ await enforceInputLimits([oldPath, patchPath], options.maxInputBytes);
+ const metadata = await inspectPatchFile(patchPath);
+ if (!metadata.valid) {
+ throw createNodeError(
+ metadata.issue === 'LEGACY_FORMAT' ? 'ELEGACYFORMAT' : 'EPATCH',
+ `cannot apply patch: ${metadata.issue || 'invalid patch'}`
+ );
+ }
+ if (
+ options.maxOutputBytes !== undefined &&
+ BigInt(metadata.declaredTargetBytes) > BigInt(options.maxOutputBytes)
+ ) {
+ throw createNodeError(
+ 'ERESOURCE',
+ `output exceeds the configured ${options.maxOutputBytes} byte limit`
+ );
+ }
+ await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
+ await runSerialized(() =>
+ runHostFileOperation('patch', [oldPath, patchPath], outputPath, options)
+ );
+ const output = await describeFile(outputPath);
+ return {
+ bytes: output.bytes,
+ outputPath,
+ sha256: output.sha256,
+ };
+}
+
+export async function verifyPatchFiles(oldPath, patchPath, expectedPath) {
+ const temporaryDirectory = await mkdtemp(
+ path.join(tmpdir(), 'react-native-bs-diff-patch-verify-')
+ );
+ const restoredPath = path.join(temporaryDirectory, 'restored.bin');
+ try {
+ const expected = await describeFile(expectedPath);
+ const restored = await patchFiles(oldPath, patchPath, restoredPath);
+ return {
+ expectedBytes: expected.bytes,
+ expectedSha256: expected.sha256,
+ restoredBytes: restored.bytes,
+ restoredSha256: restored.sha256,
+ verified:
+ restored.bytes === expected.bytes &&
+ restored.sha256 === expected.sha256,
+ };
+ } finally {
+ await rm(temporaryDirectory, { force: true, recursive: true });
+ }
+}
+
+export async function createFilePatchManifest(
+ oldPath,
+ patchPath,
+ targetPath,
+ options = {}
+) {
+ const [baseline, patch, target, metadata] = await Promise.all([
+ describeFile(oldPath, {
+ name: options.baselineName ?? path.basename(oldPath),
+ url: options.baselineUrl,
+ }),
+ describeFile(patchPath, {
+ name: options.patchName ?? path.basename(patchPath),
+ url: options.patchUrl,
+ }),
+ describeFile(targetPath, {
+ name: options.targetName ?? path.basename(targetPath),
+ url: options.targetUrl,
+ }),
+ inspectPatchFile(patchPath),
+ ]);
+ if (!metadata.valid) {
+ throw createNodeError(
+ metadata.issue === 'LEGACY_FORMAT' ? 'ELEGACYFORMAT' : 'EPATCH',
+ `cannot create manifest for patch: ${metadata.issue || 'invalid patch'}`
+ );
+ }
+ if (metadata.declaredTargetBytes !== String(target.bytes)) {
+ throw createNodeError(
+ 'ETARGETMISMATCH',
+ 'patch header target size does not match the target artifact'
+ );
+ }
+ return createPatchManifest({
+ baseline,
+ patch,
+ target,
+ releaseId: options.releaseId,
+ signature: options.signature,
+ });
+}
+
+export async function restoreVerified(
+ oldPath,
+ patchPath,
+ outputPath,
+ manifestValue,
+ options = {}
+) {
+ const manifest = validatePatchManifest(manifestValue);
+
+ const [baseline, patch] = await Promise.all([
+ describeFile(oldPath),
+ describeFile(patchPath),
+ ]);
+ if (
+ baseline.bytes !== manifest.baseline.bytes ||
+ baseline.sha256 !== manifest.baseline.sha256
+ ) {
+ throw createNodeError(
+ 'EBASELINEMISMATCH',
+ 'baseline does not match the verified patch manifest'
+ );
+ }
+ if (
+ patch.bytes !== manifest.patch.bytes ||
+ patch.sha256 !== manifest.patch.sha256
+ ) {
+ throw createNodeError(
+ 'EPATCHMISMATCH',
+ 'patch does not match the verified patch manifest'
+ );
+ }
+
+ await assertOutputAvailable(outputPath);
+ const outputDirectory = path.dirname(path.resolve(outputPath));
+ await mkdir(outputDirectory, { recursive: true });
+ const temporaryDirectory = await mkdtemp(
+ path.join(outputDirectory, '.bsdiffpatch-verified-')
+ );
+ const temporaryOutputPath = path.join(temporaryDirectory, 'restored.bin');
+ try {
+ const result = await patchFiles(oldPath, patchPath, temporaryOutputPath, {
+ ...options,
+ maxOutputBytes: Math.min(
+ options.maxOutputBytes ?? Number.MAX_SAFE_INTEGER,
+ manifest.target.bytes
+ ),
+ });
+ if (
+ result.bytes !== manifest.target.bytes ||
+ result.sha256 !== manifest.target.sha256
+ ) {
+ throw createNodeError(
+ 'ETARGETMISMATCH',
+ 'restored output does not match the verified patch manifest'
+ );
+ }
+ try {
+ await link(temporaryOutputPath, outputPath);
+ } catch (error) {
+ if (error && error.code === 'EEXIST') {
+ throw createNodeError(
+ 'EDESTEXISTS',
+ `output already exists: ${outputPath}`
+ );
+ }
+ throw error;
+ }
+ return { ...result, outputPath };
+ } finally {
+ await rm(temporaryDirectory, { force: true, recursive: true });
+ }
+}
+
+export async function convertBsdiff40File(inputPath, outputPath) {
+ await assertOutputAvailable(outputPath);
+ await assertRegularFile(inputPath);
+ const metadata = await inspectPatchFile(inputPath);
+ if (metadata.format !== 'BSDIFF40') {
+ throw createNodeError('ELEGACYFORMAT', 'input is not a BSDIFF40 patch');
+ }
+ await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
+ await runSerialized(() => runHostConverter(inputPath, outputPath));
+ const converted = await describeFile(outputPath);
+ return {
+ bytes: converted.bytes,
+ outputPath,
+ sha256: converted.sha256,
+ };
+}
diff --git a/package.json b/package.json
index dff441f..c027d43 100644
--- a/package.json
+++ b/package.json
@@ -1,25 +1,51 @@
{
"name": "react-native-bs-diff-patch",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Create and apply compact binary patches across React Native Android, iOS, and Web",
"main": "lib/commonjs/index",
"module": "lib/module/index",
"browser": "web/index.mjs",
"types": "lib/typescript/src/index.d.ts",
+ "bin": "./bin/react-native-bs-diff-patch.mjs",
"react-native": "src/index",
"source": "src/index",
"exports": {
".": {
- "types": "./lib/typescript/src/index.d.ts",
+ "types": {
+ "react-native": "./lib/typescript/src/index.d.ts",
+ "browser": "./web/index.d.mts",
+ "default": "./lib/typescript/src/index.d.ts"
+ },
"react-native": "./src/index.ts",
"browser": "./web/index.mjs",
"import": "./lib/module/index.js",
"require": "./lib/commonjs/index.js",
"default": "./lib/commonjs/index.js"
},
+ "./web": {
+ "types": "./web/index.d.mts",
+ "import": "./web/index.mjs",
+ "default": "./web/index.mjs"
+ },
+ "./node": {
+ "types": "./node/index.d.ts",
+ "node": "./node/index.mjs",
+ "import": "./node/index.mjs",
+ "default": "./node/index.mjs"
+ },
+ "./toolkit": {
+ "types": "./toolkit/index.d.ts",
+ "import": "./toolkit/index.mjs",
+ "default": "./toolkit/index.mjs"
+ },
"./package.json": "./package.json"
},
"files": [
+ "bin",
+ "action",
+ "action.yml",
+ "node",
+ "toolkit",
"src",
"lib",
"android",
@@ -36,6 +62,8 @@
"!lib/commonjs/index.web.js*",
"!lib/module/index.web.js*",
"!lib/typescript/src/index.web.d.ts*",
+ "!web/*.c",
+ "!web/*-pre.js",
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__",
@@ -51,6 +79,10 @@
"test:web:browser": "node scripts/test-web-browser.mjs",
"test:web:metro": "node scripts/test-web-metro.mjs",
"test:package": "node scripts/test-package-consumers.mjs",
+ "test:sdk": "node scripts/test-sdk-consumers.mjs",
+ "test:node": "node scripts/test-node-cli.mjs",
+ "test:toolkit": "node scripts/test-toolkit.mjs",
+ "test:action": "node scripts/test-action.mjs",
"test:registry:vite": "node scripts/test-registry-consumers.mjs vite",
"test:registry:expo": "node scripts/test-registry-consumers.mjs expo",
"test:fuzz": "sh scripts/test-native-fuzz.sh",
@@ -66,7 +98,8 @@
"typecheck": "tsc --noEmit",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
- "prepare": "bob build && node scripts/prepare-package.mjs",
+ "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"
},
@@ -130,6 +163,9 @@
"@react-native-community/cli-config/joi": "17.13.4",
"@react-native-community/cli-doctor/yaml": "2.9.0",
"@react-native-community/cli-types/joi": "17.13.4",
+ "brace-expansion@^1.1.7": "1.1.17",
+ "brace-expansion@^2.0.1": "2.1.3",
+ "brace-expansion@^5.0.5": "5.0.8",
"cosmiconfig/yaml": "1.10.3",
"eslint/ajv": "6.15.0",
"fast-xml-parser": "5.10.1",
@@ -232,7 +268,8 @@
[
"typescript",
{
- "project": "tsconfig.build.json"
+ "project": "tsconfig.build.json",
+ "tsc": "node_modules/typescript/bin/tsc"
}
]
]
diff --git a/scripts/benchmark-native.mjs b/scripts/benchmark-native.mjs
index 50afea0..a133153 100644
--- a/scripts/benchmark-native.mjs
+++ b/scripts/benchmark-native.mjs
@@ -57,6 +57,7 @@ try {
path.join(repositoryDirectory, 'cpp', 'benchmark', 'native_benchmark.c'),
path.join(repositoryDirectory, 'cpp', 'bsdiff.c'),
path.join(repositoryDirectory, 'cpp', 'bspatch.c'),
+ path.join(repositoryDirectory, 'cpp', 'bspatch_streaming.c'),
...bzip2Sources,
'-o',
executable,
diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
index 40571d6..4546b8d 100644
--- a/scripts/build-site.mjs
+++ b/scripts/build-site.mjs
@@ -16,6 +16,13 @@ const pages = [
'Install the package and complete your first native or Web patch round trip.',
file: 'getting-started.md',
},
+ {
+ slug: 'web-sdk',
+ title: 'Web and desktop SDK',
+ description:
+ 'Use public Web and toolkit exports in Vite and desktop WebViews without React Native or a Node sidecar.',
+ file: 'web-sdk.md',
+ },
{
slug: 'api-reference',
title: 'API reference',
@@ -30,6 +37,13 @@ const pages = [
'Integrity checks, temporary files, downloads, resource limits, and cross-runtime workflows.',
file: 'recipes.md',
},
+ {
+ slug: 'verified-delta-pipeline',
+ title: 'Verified Delta Pipeline',
+ description:
+ 'Node CLI, verified manifests, multi-baseline bundles, release selection, and GitHub Actions.',
+ file: 'verified-delta-pipeline.md',
+ },
{
slug: 'platform-support',
title: 'Platform support',
@@ -52,11 +66,11 @@ const pages = [
file: 'native-operations-v03.md',
},
{
- slug: 'large-files-v04',
+ slug: 'large-files-roadmap',
title: 'Large-file roadmap',
description:
'Memory baselines, honest progress semantics, and streaming feasibility for larger inputs.',
- file: 'large-files-v04.md',
+ file: 'large-files-roadmap.md',
},
{
slug: 'troubleshooting',
@@ -81,6 +95,13 @@ const chinesePages = [
description: '安装依赖,并完成第一次原生端或 Web 补丁往返。',
file: 'getting-started.md',
},
+ {
+ slug: 'web-sdk',
+ title: 'Web 与桌面 SDK',
+ description:
+ '通过公开 Web 与 toolkit 入口接入 Vite 和桌面 WebView,无需 React Native 或 Node sidecar。',
+ file: 'web-sdk.md',
+ },
{
slug: 'api-reference',
title: 'API 参考',
@@ -93,6 +114,13 @@ const chinesePages = [
description: '补丁完整性、临时文件、下载、资源限制和跨运行时流程。',
file: 'recipes.md',
},
+ {
+ slug: 'verified-delta-pipeline',
+ title: '可验证增量发布工具链',
+ description:
+ 'Node CLI、可验证 manifest、多基线 bundle、发布选择与 GitHub Actions。',
+ file: 'verified-delta-pipeline.md',
+ },
{
slug: 'platform-support',
title: '平台支持',
@@ -112,10 +140,10 @@ const chinesePages = [
file: 'native-operations-v03.md',
},
{
- slug: 'large-files-v04',
+ slug: 'large-files-roadmap',
title: '大文件演进路线',
description: '面向更大输入的内存基线、真实进度语义与流式处理可行性。',
- file: 'large-files-v04.md',
+ file: 'large-files-roadmap.md',
},
{
slug: 'troubleshooting',
@@ -314,6 +342,8 @@ const englishUi = {
skipLabel: 'Skip to documentation',
primaryNavigationLabel: 'Primary navigation',
playgroundLabel: 'Playground',
+ plannerLabel: 'Release Planner',
+ plannerPath: '/planner/',
toolsLabel: 'Tools',
toolsPath: '/tools/',
docsLabel: 'Docs',
@@ -340,6 +370,8 @@ const chineseUi = {
skipLabel: '跳到文档正文',
primaryNavigationLabel: '主导航',
playgroundLabel: '在线实验',
+ plannerLabel: '发布规划',
+ plannerPath: '/zh-CN/planner/',
toolsLabel: '工具',
toolsPath: '/zh-CN/tools/',
docsLabel: '中文文档',
@@ -419,6 +451,7 @@ function documentationLayout({ slug, title, description, content, items, ui }) {
${ui.playgroundLabel}
${ui.toolsLabel}
+ ${ui.plannerLabel}
${ui.docsLabel}
${
ui.alternateLabel
@@ -452,8 +485,10 @@ function documentationLayout({ slug, title, description, content, items, ui }) {
${ui.footerText}
${
ui.homeLabel
- } ${
- ui.toolsLabel
+ } ${ui.toolsLabel} ${
+ ui.plannerLabel
} npm GitHub
@@ -518,6 +553,7 @@ function localizeHomepage(source) {
['aria-label="Primary navigation"', 'aria-label="主导航"'],
['>Playground ', '>在线实验'],
['>Tools', '>工具'],
+ ['>Release Planner', '>发布规划'],
['>Architecture', '>架构'],
['>Evidence', '>验证'],
['Docs ', '文档 '],
@@ -758,6 +794,8 @@ const englishToolsUi = {
PAGE_TITLE: 'Binary Patch Toolkit — react-native-bs-diff-patch',
PLAYGROUND_LABEL: 'Playground',
PLAYGROUND_PATH: '/#playground',
+ PLANNER_LABEL: 'Release Planner',
+ PLANNER_PATH: '/planner/',
PRIMARY_NAVIGATION_LABEL: 'Primary navigation',
READY_LABEL: 'Ready',
RECIPES_PATH: '/docs/recipes/',
@@ -792,6 +830,8 @@ const chineseToolsUi = {
PAGE_TITLE: '二进制补丁工具箱 — react-native-bs-diff-patch',
PLAYGROUND_LABEL: '在线实验',
PLAYGROUND_PATH: '/zh-CN/#playground',
+ PLANNER_LABEL: '发布规划',
+ PLANNER_PATH: '/zh-CN/planner/',
PRIMARY_NAVIGATION_LABEL: '主导航',
READY_LABEL: '就绪',
RECIPES_PATH: '/docs/zh-CN/recipes/',
@@ -811,6 +851,64 @@ function renderToolsPage(template, ui) {
});
}
+const englishPlannerUi = {
+ ALTERNATE_LABEL: '中文',
+ ALTERNATE_LANGUAGE: 'zh-CN',
+ ALTERNATE_PATH: '/zh-CN/planner/',
+ CANONICAL_PATH: '/planner/',
+ CLOSE_LABEL: 'Close',
+ DOCS_LABEL: 'Docs',
+ DOCS_PATH: '/docs/',
+ FOOTER_NAVIGATION_LABEL: 'Footer navigation',
+ FOOTER_TEXT: 'MIT licensed. Built for React Native runtimes.',
+ HOME_ARIA_LABEL: 'react-native-bs-diff-patch home',
+ HOME_LABEL: 'Home',
+ HOME_PATH: '/',
+ LANG: 'en',
+ MENU_LABEL: 'Menu',
+ META_DESCRIPTION:
+ 'Build a multi-baseline binary patch matrix, verified bundle manifest, and full-file fallback plan locally in your browser.',
+ NO_FILE_LABEL: 'No file selected',
+ PAGE_TITLE: 'Release Planner — react-native-bs-diff-patch',
+ PLANNER_LABEL: 'Release Planner',
+ PRIMARY_NAVIGATION_LABEL: 'Primary navigation',
+ READY_LABEL: 'Ready to plan a release',
+ REPORT_EMPTY: 'The patch matrix will appear after planning.',
+ RUNTIME_LOADING: 'Loading Web API',
+ SKIP_LABEL: 'Skip to release planner',
+ TOOLS_LABEL: 'Tools',
+ TOOLS_PATH: '/tools/',
+};
+
+const chinesePlannerUi = {
+ ALTERNATE_LABEL: 'English',
+ ALTERNATE_LANGUAGE: 'en',
+ ALTERNATE_PATH: '/planner/',
+ CANONICAL_PATH: '/zh-CN/planner/',
+ CLOSE_LABEL: '关闭',
+ DOCS_LABEL: '中文文档',
+ DOCS_PATH: '/docs/zh-CN/',
+ FOOTER_NAVIGATION_LABEL: '页脚导航',
+ FOOTER_TEXT: 'MIT 许可,为 React Native 多运行时构建。',
+ HOME_ARIA_LABEL: 'react-native-bs-diff-patch 中文首页',
+ HOME_LABEL: '首页',
+ HOME_PATH: '/zh-CN/',
+ LANG: 'zh-CN',
+ MENU_LABEL: '菜单',
+ META_DESCRIPTION:
+ '直接在浏览器本地生成多基线补丁矩阵、可验证 bundle manifest 和完整文件回退计划。',
+ NO_FILE_LABEL: '尚未选择文件',
+ PAGE_TITLE: '发布规划 — react-native-bs-diff-patch',
+ PLANNER_LABEL: '发布规划',
+ PRIMARY_NAVIGATION_LABEL: '主导航',
+ READY_LABEL: '准备好规划发布',
+ REPORT_EMPTY: '生成计划后将在这里显示补丁矩阵。',
+ RUNTIME_LOADING: '正在加载 Web API',
+ SKIP_LABEL: '跳到发布规划正文',
+ TOOLS_LABEL: '工具',
+ TOOLS_PATH: '/zh-CN/tools/',
+};
+
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
await cp(
@@ -869,6 +967,26 @@ await writeFile(
renderToolsPage(toolsTemplate, chineseToolsUi)
);
+const plannerTemplate = await readFile(
+ path.join(siteDirectory, 'planner', 'index.html'),
+ 'utf8'
+);
+const plannerOutputDirectory = path.join(outputDirectory, 'planner');
+await mkdir(plannerOutputDirectory, { recursive: true });
+await writeFile(
+ path.join(plannerOutputDirectory, 'index.html'),
+ renderToolsPage(plannerTemplate, englishPlannerUi)
+);
+const chinesePlannerOutputDirectory = path.join(
+ chineseHomepageDirectory,
+ 'planner'
+);
+await mkdir(chinesePlannerOutputDirectory, { recursive: true });
+await writeFile(
+ path.join(chinesePlannerOutputDirectory, 'index.html'),
+ renderToolsPage(plannerTemplate, chinesePlannerUi)
+);
+
await cp(
path.join(repositoryDirectory, 'web'),
path.join(outputDirectory, 'web'),
@@ -876,6 +994,17 @@ await cp(
recursive: true,
}
);
+await rm(path.join(outputDirectory, 'web', 'progress_bridge.c'));
+await rm(path.join(outputDirectory, 'web', 'minimal-runtime-pre.js'), {
+ force: true,
+});
+await cp(
+ path.join(repositoryDirectory, 'toolkit'),
+ path.join(outputDirectory, 'toolkit'),
+ {
+ recursive: true,
+ }
+);
const docsOutputDirectory = path.join(outputDirectory, 'docs');
await mkdir(docsOutputDirectory, { recursive: true });
diff --git a/scripts/build-web-wasm.sh b/scripts/build-web-wasm.sh
index b238951..13cd385 100644
--- a/scripts/build-web-wasm.sh
+++ b/scripts/build-web-wasm.sh
@@ -13,7 +13,10 @@ fi
"${emcc_bin}" \
"${repo_dir}/cpp/bsdiff.c" \
+ "${repo_dir}/cpp/bsdiff40_converter.c" \
"${repo_dir}/cpp/bspatch.c" \
+ "${repo_dir}/cpp/bspatch_streaming.c" \
+ "${repo_dir}/web/progress_bridge.c" \
"${repo_dir}/cpp/bzlib/blocksort.c" \
"${repo_dir}/cpp/bzlib/bzlib.c" \
"${repo_dir}/cpp/bzlib/compress.c" \
@@ -25,15 +28,51 @@ fi
-I"${repo_dir}/cpp/bzlib" \
-O3 \
-flto \
+ -lnodefs.js \
+ -lworkerfs.js \
--no-entry \
-sASSERTIONS=0 \
-sALLOW_MEMORY_GROWTH=1 \
-sENVIRONMENT=web,worker,node \
- -sEXPORTED_FUNCTIONS='["_bsDiffFile","_bsPatchFile"]' \
- -sEXPORTED_RUNTIME_METHODS='["FS","ccall"]' \
+ -sEXPORTED_FUNCTIONS='["_bsDiffFile","_bsPatchFile","_bsConvertBsdiff40File","_bsDiffFileWithProgress","_bsPatchFileWithProgress","_bsDiffFileWithProgressAndLimits","_bsPatchFileWithProgressAndLimits"]' \
+ -sEXPORTED_RUNTIME_METHODS='["FS","NODEFS","WORKERFS","ccall"]' \
-sEXPORT_ES6=1 \
-sFILESYSTEM=1 \
-sMODULARIZE=1 \
-sNO_EXIT_RUNTIME=1 \
-sSINGLE_FILE=1 \
-o "${repo_dir}/web/bsdiffpatch.mjs"
+
+"${emcc_bin}" \
+ "${repo_dir}/cpp/bsdiff.c" \
+ "${repo_dir}/cpp/bsdiff40_converter.c" \
+ "${repo_dir}/cpp/bspatch.c" \
+ "${repo_dir}/cpp/bspatch_streaming.c" \
+ "${repo_dir}/web/progress_bridge.c" \
+ "${repo_dir}/cpp/bzlib/blocksort.c" \
+ "${repo_dir}/cpp/bzlib/bzlib.c" \
+ "${repo_dir}/cpp/bzlib/compress.c" \
+ "${repo_dir}/cpp/bzlib/crctable.c" \
+ "${repo_dir}/cpp/bzlib/decompress.c" \
+ "${repo_dir}/cpp/bzlib/huffman.c" \
+ "${repo_dir}/cpp/bzlib/randtable.c" \
+ -I"${repo_dir}/cpp" \
+ -I"${repo_dir}/cpp/bzlib" \
+ -O3 \
+ -flto \
+ -lworkerfs.js \
+ --no-entry \
+ --pre-js "${repo_dir}/web/minimal-runtime-pre.js" \
+ -sASSERTIONS=0 \
+ -sMINIMAL_RUNTIME=1 \
+ -sEXPORT_ALL=1 \
+ -sALLOW_MEMORY_GROWTH=1 \
+ -sENVIRONMENT=web,worker \
+ -sEXPORTED_FUNCTIONS='["_bsDiffFile","_bsPatchFile","_bsConvertBsdiff40File","_bsDiffFileWithProgress","_bsPatchFileWithProgress","_bsDiffFileWithProgressAndLimits","_bsPatchFileWithProgressAndLimits"]' \
+ -sEXPORTED_RUNTIME_METHODS='["FS","WORKERFS","ccall"]' \
+ -sEXPORT_ES6=1 \
+ -sFILESYSTEM=1 \
+ -sMODULARIZE=1 \
+ -sNO_EXIT_RUNTIME=1 \
+ -sSINGLE_FILE=1 \
+ -o "${repo_dir}/web/bsdiffpatch.browser.mjs"
diff --git a/scripts/check-package-contract.mjs b/scripts/check-package-contract.mjs
new file mode 100644
index 0000000..55f0474
--- /dev/null
+++ b/scripts/check-package-contract.mjs
@@ -0,0 +1,101 @@
+import assert from 'node:assert/strict';
+import { access, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const manifest = JSON.parse(
+ await readFile(path.join(root, 'package.json'), 'utf8')
+);
+const rootExport = manifest.exports['.'];
+assert.equal(rootExport.browser, './web/index.mjs');
+assert.equal(rootExport['react-native'], './src/index.ts');
+assert.equal(rootExport.import, './lib/module/index.js');
+assert.equal(rootExport.require, './lib/commonjs/index.js');
+assert.equal(rootExport.types.browser, './web/index.d.mts');
+assert.equal(rootExport.types['react-native'], rootExport.types.default);
+assert.equal(manifest.exports['./web'].types, './web/index.d.mts');
+assert.equal(manifest.exports['./web'].import, './web/index.mjs');
+assert.equal(manifest.exports['./toolkit'].import, './toolkit/index.mjs');
+assert.equal(manifest.exports['./node'].node, './node/index.mjs');
+assert.equal(manifest.peerDependenciesMeta.react.optional, true);
+assert.equal(manifest.peerDependenciesMeta['react-native'].optional, true);
+
+async function checkExportTargets(value) {
+ if (typeof value === 'string') {
+ assert.ok(
+ value.startsWith('./'),
+ `Export target must be package-relative: ${value}`
+ );
+ await access(path.join(root, value));
+ } else if (value && typeof value === 'object') {
+ for (const target of Object.values(value)) await checkExportTargets(target);
+ }
+}
+await checkExportTargets(manifest.exports);
+for (const directory of ['web', 'toolkit', 'node', 'bin', 'action']) {
+ assert.ok(manifest.files.includes(directory), `${directory} must be packed`);
+}
+for (const relative of [
+ 'web/bsdiffpatch.mjs',
+ 'web/bsdiffpatch.browser.mjs',
+ 'action.yml',
+ 'action/index.mjs',
+ 'bin/react-native-bs-diff-patch.mjs',
+])
+ await access(path.join(root, relative));
+
+// Follow real imports and module Worker URL literals, including generated JS.
+// No aliases, externals or shims can satisfy this source-graph contract.
+const visited = new Set();
+async function checkBrowserGraph(relative) {
+ const absolute = path.resolve(root, relative);
+ if (visited.has(absolute)) return;
+ visited.add(absolute);
+ const source = await readFile(absolute, 'utf8');
+ assert.doesNotMatch(
+ source,
+ /['"]node:|\bNODEFS\b|\bENVIRONMENT_IS_NODE\b|\b__dirname\b|\brequire\s*\(/,
+ `Browser dependency contains a Node runtime branch: ${relative}`
+ );
+ for (const match of source.matchAll(/['"](\.\.?\/[^'"\n]+\.mjs)['"]/g)) {
+ await checkBrowserGraph(
+ path.relative(root, path.resolve(path.dirname(absolute), match[1]))
+ );
+ }
+}
+await checkBrowserGraph('web/index.mjs');
+assert.ok(
+ visited.has(path.join(root, 'web/bsdiffpatch.browser.mjs')),
+ 'The public Web entry must reach the dedicated browser build'
+);
+assert.ok(
+ !visited.has(path.join(root, 'web/bsdiffpatch.mjs')),
+ 'The public Web entry must not reach the Node-compatible build'
+);
+assert.match(
+ await readFile(path.join(root, 'node/index.mjs'), 'utf8'),
+ /web\/bsdiffpatch\.mjs/
+);
+assert.match(
+ await readFile(path.join(root, 'web/bsdiffpatch.mjs'), 'utf8'),
+ /NODEFS/
+);
+
+for (const [entry, declaration] of [
+ ['web/index.mjs', 'web/index.d.mts'],
+ ['toolkit/index.mjs', 'toolkit/index.d.ts'],
+]) {
+ 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`
+ );
+ }
+}
+console.log(
+ `Package ${manifest.version}: exports, public declarations, assets and Node-free browser graph passed`
+);
diff --git a/scripts/test-action.mjs b/scripts/test-action.mjs
new file mode 100644
index 0000000..8bd585d
--- /dev/null
+++ b/scripts/test-action.mjs
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import { execFile } from 'node:child_process';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+const tempDirectory = await mkdtemp(
+ path.join(os.tmpdir(), 'bsdiffpatch-action-test-')
+);
+try {
+ const oldPath = path.join(tempDirectory, 'old.bin');
+ const newPath = path.join(tempDirectory, 'new.bin');
+ const patchPath = path.join(tempDirectory, 'update.patch');
+ const manifestPath = path.join(tempDirectory, 'manifest.json');
+ const outputPath = path.join(tempDirectory, 'github-output.txt');
+ await Promise.all([
+ writeFile(oldPath, 'baseline\n'.repeat(128)),
+ writeFile(newPath, 'target\n'.repeat(128)),
+ writeFile(outputPath, ''),
+ ]);
+
+ await execFileAsync(process.execPath, [path.resolve('action/index.mjs')], {
+ env: {
+ ...process.env,
+ 'GITHUB_OUTPUT': outputPath,
+ 'INPUT_MANIFEST-FILE': manifestPath,
+ 'INPUT_MAX-PATCH-RATIO': '1',
+ 'INPUT_NEW-FILE': newPath,
+ 'INPUT_OLD-FILE': oldPath,
+ 'INPUT_PATCH-FILE': patchPath,
+ 'INPUT_RELEASE-ID': 'test-release',
+ },
+ });
+ const outputs = Object.fromEntries(
+ (await readFile(outputPath, 'utf8'))
+ .trim()
+ .split('\n')
+ .map((line) => line.split('=', 2))
+ );
+ assert.equal(outputs.strategy, 'patch');
+ assert.equal(outputs['patch-file'], patchPath);
+ assert.equal(
+ JSON.parse(await readFile(manifestPath, 'utf8')).releaseId,
+ 'test-release'
+ );
+
+ await assert.rejects(
+ execFileAsync(process.execPath, [path.resolve('action/index.mjs')], {
+ env: {
+ ...process.env,
+ 'INPUT_NEW-FILE': newPath,
+ 'INPUT_OLD-FILE': `${tempDirectory}/missing\n::warning::injected`,
+ },
+ }),
+ (error) =>
+ error &&
+ error.stderr.includes('%0A::warning::injected') &&
+ !error.stderr.includes('\n::warning::injected')
+ );
+} finally {
+ await rm(tempDirectory, { force: true, recursive: true });
+}
+
+console.log('GitHub Action test passed');
diff --git a/scripts/test-native-fuzz.sh b/scripts/test-native-fuzz.sh
index b1a0d31..cbb2b6d 100755
--- a/scripts/test-native-fuzz.sh
+++ b/scripts/test-native-fuzz.sh
@@ -26,6 +26,7 @@ if "$compiler" \
-fsanitize=fuzzer,address,undefined \
-I"$repository_directory/cpp" \
"$repository_directory/cpp/bspatch.c" \
+ "$repository_directory/cpp/bspatch_streaming.c" \
"$repository_directory/cpp/fuzz/bspatch_fuzzer.c" \
"$repository_directory/cpp/bzlib/blocksort.c" \
"$repository_directory/cpp/bzlib/bzlib.c" \
@@ -50,6 +51,7 @@ else
-fsanitize=address,undefined \
-I"$repository_directory/cpp" \
"$repository_directory/cpp/bspatch.c" \
+ "$repository_directory/cpp/bspatch_streaming.c" \
"$repository_directory/cpp/fuzz/bspatch_fuzzer.c" \
"$repository_directory/cpp/bzlib/blocksort.c" \
"$repository_directory/cpp/bzlib/bzlib.c" \
diff --git a/scripts/test-native-operations.sh b/scripts/test-native-operations.sh
index 7bb1510..2f63c94 100755
--- a/scripts/test-native-operations.sh
+++ b/scripts/test-native-operations.sh
@@ -16,7 +16,9 @@ cc -std=c11 "$feature_test_macro" -O2 -Wall -Wextra -Werror \
-I "$repository_directory/cpp" \
"$repository_directory/cpp/tests/native_operations_test.c" \
"$repository_directory/cpp/bsdiff.c" \
+ "$repository_directory/cpp/bsdiff40_converter.c" \
"$repository_directory/cpp/bspatch.c" \
+ "$repository_directory/cpp/bspatch_streaming.c" \
"$repository_directory/cpp/bzlib/blocksort.c" \
"$repository_directory/cpp/bzlib/bzlib.c" \
"$repository_directory/cpp/bzlib/compress.c" \
diff --git a/scripts/test-node-cli.mjs b/scripts/test-node-cli.mjs
new file mode 100644
index 0000000..e76e757
--- /dev/null
+++ b/scripts/test-node-cli.mjs
@@ -0,0 +1,204 @@
+import assert from 'node:assert/strict';
+import { execFile } from 'node:child_process';
+import {
+ access,
+ mkdir,
+ mkdtemp,
+ readFile,
+ readdir,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { promisify } from 'node:util';
+
+import {
+ createFilePatchManifest,
+ convertBsdiff40File,
+ diffFiles,
+ inspectPatchFile,
+ restoreVerified,
+ verifyPatchFiles,
+} from '../node/index.mjs';
+
+const execFileAsync = promisify(execFile);
+const tempDirectory = await mkdtemp(
+ path.join(os.tmpdir(), 'bsdiffpatch-node-test-')
+);
+const oldPath = path.join(tempDirectory, 'old.bin');
+const newPath = path.join(tempDirectory, 'new.bin');
+const patchPath = path.join(tempDirectory, 'update.patch');
+const restoredPath = path.join(tempDirectory, 'restored.bin');
+const cliRestoredPath = path.join(tempDirectory, 'cli-restored.bin');
+const cliPath = path.resolve('bin/react-native-bs-diff-patch.mjs');
+const invalidLegacyPath = path.join(tempDirectory, 'invalid-bsdiff40.patch');
+const invalidConvertedPath = path.join(
+ tempDirectory,
+ 'invalid-converted.patch'
+);
+const baselineDirectory = path.join(tempDirectory, 'releases');
+const bundleDirectory = path.join(tempDirectory, 'bundle');
+const mismatchedTargetPath = path.join(tempDirectory, 'mismatched-target.bin');
+const limitedDiffPath = path.join(tempDirectory, 'limited-diff.patch');
+const limitedDiffOutputPath = path.join(
+ tempDirectory,
+ 'limited-diff-output.patch'
+);
+const limitedPatchPath = path.join(tempDirectory, 'limited-patch.bin');
+const occupiedRestorePath = path.join(tempDirectory, 'occupied.bin');
+const mismatchedRestorePath = path.join(
+ tempDirectory,
+ 'mismatched-restore.bin'
+);
+
+try {
+ const oldData = Buffer.from('old release payload\n'.repeat(128));
+ const newData = Buffer.from(
+ 'new release payload\n'.repeat(96) + 'verified delta\n'.repeat(32)
+ );
+ await mkdir(baselineDirectory);
+ await Promise.all([
+ writeFile(oldPath, oldData),
+ writeFile(newPath, newData),
+ writeFile(mismatchedTargetPath, Buffer.concat([newData, Buffer.from('!')])),
+ writeFile(path.join(baselineDirectory, 'v1.bin'), oldData),
+ writeFile(
+ path.join(baselineDirectory, 'v1.1.bin'),
+ Buffer.concat([oldData.subarray(0, oldData.length - 1), Buffer.from('!')])
+ ),
+ writeFile(
+ invalidLegacyPath,
+ Buffer.concat([Buffer.from('BSDIFF40'), Buffer.alloc(24)])
+ ),
+ ]);
+
+ await assert.rejects(
+ convertBsdiff40File(invalidLegacyPath, invalidConvertedPath),
+ (error) => error && error.code === 'ELEGACYFORMAT'
+ );
+ await assert.rejects(access(invalidConvertedPath), { code: 'ENOENT' });
+
+ const progressEvents = [];
+ const diffResult = await diffFiles(oldPath, newPath, patchPath, {
+ onProgress: (event) => progressEvents.push(event),
+ });
+ assert.ok(diffResult.bytes > 24);
+ assert.ok(
+ progressEvents.some(
+ (event) => event.phase === 'processing' && event.progress > 0
+ )
+ );
+ const metadata = await inspectPatchFile(patchPath);
+ assert.equal(metadata.valid, true);
+ assert.equal(metadata.declaredTargetBytes, String(newData.byteLength));
+ assert.equal(
+ (await verifyPatchFiles(oldPath, patchPath, newPath)).verified,
+ true
+ );
+
+ const manifest = await createFilePatchManifest(oldPath, patchPath, newPath);
+ await writeFile(occupiedRestorePath, 'keep existing destination');
+ await assert.rejects(
+ restoreVerified(oldPath, patchPath, occupiedRestorePath, manifest),
+ (error) => error && error.code === 'EDESTEXISTS'
+ );
+ assert.equal(
+ await readFile(occupiedRestorePath, 'utf8'),
+ 'keep existing destination'
+ );
+ await assert.rejects(
+ restoreVerified(oldPath, patchPath, mismatchedRestorePath, {
+ ...manifest,
+ target: { ...manifest.target, sha256: '0'.repeat(64) },
+ }),
+ (error) => error && error.code === 'ETARGETMISMATCH'
+ );
+ await assert.rejects(access(mismatchedRestorePath), { code: 'ENOENT' });
+ assert.equal(
+ (await readdir(tempDirectory)).some((name) =>
+ name.startsWith('.bsdiffpatch-verified-')
+ ),
+ false
+ );
+ await assert.rejects(
+ createFilePatchManifest(oldPath, patchPath, mismatchedTargetPath),
+ (error) => error && error.code === 'ETARGETMISMATCH'
+ );
+ await assert.rejects(
+ diffFiles(oldPath, newPath, limitedDiffPath, {
+ maxInputBytes: oldData.byteLength - 1,
+ }),
+ (error) => error && error.code === 'ERESOURCE'
+ );
+ await assert.rejects(access(limitedDiffPath), { code: 'ENOENT' });
+ await assert.rejects(
+ diffFiles(oldPath, newPath, limitedDiffOutputPath, {
+ maxOutputBytes: diffResult.bytes - 1,
+ }),
+ (error) => error && error.code === 'ERESOURCE'
+ );
+ await assert.rejects(access(limitedDiffOutputPath), { code: 'ENOENT' });
+ await assert.rejects(
+ restoreVerified(oldPath, patchPath, limitedPatchPath, manifest, {
+ maxOutputBytes: newData.byteLength - 1,
+ }),
+ (error) => error && error.code === 'ERESOURCE'
+ );
+ await assert.rejects(access(limitedPatchPath), { code: 'ENOENT' });
+ await restoreVerified(oldPath, patchPath, restoredPath, manifest);
+ assert.deepEqual(await readFile(restoredPath), newData);
+
+ const inspectResult = await execFileAsync(process.execPath, [
+ cliPath,
+ 'inspect',
+ patchPath,
+ '--json',
+ ]);
+ assert.equal(JSON.parse(inspectResult.stdout).valid, true);
+ await execFileAsync(process.execPath, [
+ cliPath,
+ 'patch',
+ oldPath,
+ patchPath,
+ '-o',
+ cliRestoredPath,
+ ]);
+ assert.deepEqual(await readFile(cliRestoredPath), newData);
+ const verifyResult = await execFileAsync(process.execPath, [
+ cliPath,
+ 'verify',
+ oldPath,
+ patchPath,
+ newPath,
+ ]);
+ assert.equal(JSON.parse(verifyResult.stdout).verified, true);
+
+ const bundleResult = await execFileAsync(process.execPath, [
+ cliPath,
+ 'bundle',
+ '--from',
+ baselineDirectory,
+ '--to',
+ newPath,
+ '--out',
+ bundleDirectory,
+ '--max-ratio',
+ '1',
+ '--release-id',
+ 'v2',
+ ]);
+ assert.equal(JSON.parse(bundleResult.stdout).decisions.length, 2);
+ const bundleManifest = JSON.parse(
+ await readFile(path.join(bundleDirectory, 'bundle-manifest.json'), 'utf8')
+ );
+ assert.equal(
+ bundleManifest.format,
+ 'react-native-bs-diff-patch/verified-bundle-v1'
+ );
+ assert.equal(bundleManifest.releaseId, 'v2');
+} finally {
+ await rm(tempDirectory, { force: true, recursive: true });
+}
+
+console.log('Node API and CLI tests passed');
diff --git a/scripts/test-package-consumers.mjs b/scripts/test-package-consumers.mjs
index cc3c6bb..1774b0e 100644
--- a/scripts/test-package-consumers.mjs
+++ b/scripts/test-package-consumers.mjs
@@ -18,6 +18,9 @@ const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), 'react-native-bs-diff-patch-consumer-')
);
const consumerDirectory = path.join(temporaryDirectory, 'consumer');
+const suppliedTarball = process.env.PACKAGE_TARBALL
+ ? path.resolve(process.env.PACKAGE_TARBALL)
+ : undefined;
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -100,18 +103,25 @@ async function pathExists(candidate) {
}
try {
- const packOutput = normalizePackEntries(
- parseTrailingJson(
- run('npm', [
- 'pack',
- '--ignore-scripts',
- '--json',
- '--pack-destination',
+ if (suppliedTarball) {
+ await access(suppliedTarball);
+ }
+ const tarballPath = suppliedTarball
+ ? suppliedTarball
+ : path.join(
temporaryDirectory,
- ])
- )
- );
- const tarballPath = path.join(temporaryDirectory, packOutput[0].filename);
+ normalizePackEntries(
+ parseTrailingJson(
+ run('npm', [
+ 'pack',
+ '--ignore-scripts',
+ '--json',
+ '--pack-destination',
+ temporaryDirectory,
+ ])
+ )
+ )[0].filename
+ );
await mkdir(consumerDirectory, { recursive: true });
await writeFile(
@@ -122,18 +132,9 @@ try {
2
)}\n`
);
- run(
- 'npm',
- [
- 'install',
- tarballPath,
- '--ignore-scripts',
- '--no-audit',
- '--no-fund',
- '--package-lock=false',
- ],
- { cwd: consumerDirectory }
- );
+ run('npm', ['install', tarballPath, '--no-audit', '--no-fund'], {
+ cwd: consumerDirectory,
+ });
assert.equal(
await pathExists(path.join(consumerDirectory, 'node_modules/react')),
@@ -145,6 +146,16 @@ try {
false,
'A browser-only install must not auto-install the optional React Native peer'
);
+ const dependencyTree = JSON.parse(
+ run('npm', ['ls', '--omit=dev', '--all', '--json'], {
+ cwd: consumerDirectory,
+ })
+ );
+ assert.equal(
+ Object.hasOwn(dependencyTree.dependencies || {}, 'react-native'),
+ false,
+ 'The packed browser consumer dependency tree must not contain React Native'
+ );
const fakeReactNativeDirectory = path.join(
consumerDirectory,
@@ -182,6 +193,23 @@ try {
await readFile(path.join(installedPackageDirectory, 'package.json'), 'utf8')
);
assert.equal(installedManifest.exports['.'].browser, './web/index.mjs');
+ assert.equal(
+ installedManifest.exports['.'].types.browser,
+ './web/index.d.mts'
+ );
+ assert.equal(installedManifest.exports['./web'].import, './web/index.mjs');
+ assert.equal(installedManifest.exports['./web'].types, './web/index.d.mts');
+ assert.equal(installedManifest.exports['./node'].node, './node/index.mjs');
+ assert.equal(
+ installedManifest.exports['./toolkit'].import,
+ './toolkit/index.mjs'
+ );
+ assert.equal(
+ typeof installedManifest.bin === 'string'
+ ? installedManifest.bin
+ : installedManifest.bin['react-native-bs-diff-patch'],
+ './bin/react-native-bs-diff-patch.mjs'
+ );
assert.equal(
installedManifest.exports['.']['react-native'],
'./src/index.ts'
@@ -204,6 +232,14 @@ try {
run('node', ['resolve.mjs'], { cwd: consumerDirectory }),
/lib\/module\/index\.js$/
);
+ await writeFile(
+ path.join(consumerDirectory, 'resolve-web.mjs'),
+ "console.log(import.meta.resolve('react-native-bs-diff-patch/web'));\n"
+ );
+ assert.match(
+ run('node', ['resolve-web.mjs'], { cwd: consumerDirectory }),
+ /web\/index\.mjs$/
+ );
await writeFile(
path.join(consumerDirectory, 'load.mjs'),
@@ -243,14 +279,80 @@ try {
cwd: consumerDirectory,
});
+ await writeFile(
+ path.join(consumerDirectory, 'web.mjs'),
+ [
+ "import { diffBytes, inspectPatch, startDiffBytes } from 'react-native-bs-diff-patch/web';",
+ "if (typeof diffBytes !== 'function' || typeof inspectPatch !== 'function' || typeof startDiffBytes !== 'function') throw new Error('Missing explicit Web API');",
+ ].join('\n')
+ );
+ run('node', ['web.mjs'], { cwd: consumerDirectory });
+
+ await writeFile(
+ path.join(consumerDirectory, 'pipeline.mjs'),
+ [
+ "import { inspectPatchFile } from 'react-native-bs-diff-patch/node';",
+ "import { canonicalJson, createPatchManifest } from 'react-native-bs-diff-patch/toolkit';",
+ "if (typeof inspectPatchFile !== 'function') throw new Error('Missing Node API');",
+ 'if (canonicalJson({ b: 1, a: 2 }) !== \'{"a":2,"b":1}\') throw new Error(\'Toolkit mismatch\');',
+ "if (createPatchManifest({ baseline: { bytes: 1, sha256: '1'.repeat(64) }, patch: { bytes: 1, sha256: '2'.repeat(64) }, target: { bytes: 1, sha256: '3'.repeat(64) } }).version !== 1) throw new Error('Manifest mismatch');",
+ ].join('\n')
+ );
+ run('node', ['pipeline.mjs'], { cwd: consumerDirectory });
+ const installedCliPath = path.join(
+ installedPackageDirectory,
+ 'bin/react-native-bs-diff-patch.mjs'
+ );
+ const oldArtifactPath = path.join(consumerDirectory, 'old.bin');
+ const newArtifactPath = path.join(consumerDirectory, 'new.bin');
+ const patchArtifactPath = path.join(consumerDirectory, 'update.patch');
+ await Promise.all([
+ writeFile(oldArtifactPath, 'packed old artifact\n'.repeat(32)),
+ writeFile(newArtifactPath, 'packed new artifact\n'.repeat(32)),
+ ]);
+ run(
+ 'node',
+ [
+ installedCliPath,
+ 'diff',
+ oldArtifactPath,
+ newArtifactPath,
+ '-o',
+ patchArtifactPath,
+ ],
+ { cwd: consumerDirectory }
+ );
+ run(
+ 'node',
+ [
+ installedCliPath,
+ 'verify',
+ oldArtifactPath,
+ patchArtifactPath,
+ newArtifactPath,
+ ],
+ { cwd: consumerDirectory }
+ );
+
await writeFile(
path.join(consumerDirectory, 'consumer.ts'),
[
"import { diffBytes, inspectPatch, verifyPatch, type BinaryInput, type PatchMetadata } from 'react-native-bs-diff-patch';",
+ "import { startDiff as startWebDiff, type BinaryOperationJob } from 'react-native-bs-diff-patch/web';",
+ "import { inspectPatchFile, type NodeOperationResult } from 'react-native-bs-diff-patch/node';",
+ "import { createPatchManifest, type PatchManifest } from 'react-native-bs-diff-patch/toolkit';",
'const input: BinaryInput = new Uint8Array([1, 2, 3]);',
'void diffBytes(input, input);',
'void inspectPatch(input).then((value: PatchMetadata) => value.valid);',
'void verifyPatch(input, input, input).then((value) => value.verified);',
+ 'void inspectPatchFile("update.patch").then((value) => value.valid);',
+ 'const manifest: PatchManifest = createPatchManifest({ baseline: { bytes: 1, sha256: "1".repeat(64) }, patch: { bytes: 1, sha256: "2".repeat(64) }, target: { bytes: 1, sha256: "3".repeat(64) } });',
+ 'const result: NodeOperationResult | undefined = undefined;',
+ 'const job: BinaryOperationJob = startWebDiff(input, input);',
+ 'const jobResult: Promise = job.result;',
+ '// @ts-expect-error Explicit Web types must reject native path arguments.',
+ 'startWebDiff("old", "new", "patch");',
+ 'void manifest; void result; void jobResult;',
].join('\n')
);
run(
@@ -259,6 +361,8 @@ try {
path.join(repositoryDirectory, 'node_modules/typescript/bin/tsc'),
'--noEmit',
'--strict',
+ '--skipLibCheck',
+ 'false',
'--target',
'ES2022',
'--module',
diff --git a/scripts/test-sdk-consumers.mjs b/scripts/test-sdk-consumers.mjs
new file mode 100644
index 0000000..80d6067
--- /dev/null
+++ b/scripts/test-sdk-consumers.mjs
@@ -0,0 +1,870 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { existsSync } from 'node:fs';
+import {
+ access,
+ mkdir,
+ mkdtemp,
+ readFile,
+ readdir,
+ realpath,
+ rm,
+ stat,
+ writeFile,
+} from 'node:fs/promises';
+import { createServer } from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import puppeteer from 'puppeteer-core';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repositoryDirectory = path.resolve(scriptDirectory, '..');
+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-')
+ )
+);
+const chromeCandidates = [
+ process.env.CHROME_PATH,
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/google-chrome-stable',
+ '/usr/bin/google-chrome',
+ '/usr/bin/chromium',
+ '/usr/bin/chromium-browser',
+].filter(Boolean);
+const browserCsp =
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'";
+const registry040TarballUrl =
+ 'https://registry.npmjs.org/react-native-bs-diff-patch/-/react-native-bs-diff-patch-0.4.0.tgz';
+const registry040Integrity =
+ 'sha512-pQXEVIn9yx8zqYtJjnw2xws3g3EB8E2Qz8N5WTwyUtUSpW/fhBFUtE1ACqbVvW6LE+7ndy7NjlrTJIERD0Y0lQ==';
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd || repositoryDirectory,
+ encoding: 'utf8',
+ env: { ...process.env, CI: '1', ...options.env },
+ });
+
+ if (result.status !== 0) {
+ throw new Error(
+ `${command} ${args.join(' ')} failed:\n${result.stdout || ''}${
+ result.stderr || ''
+ }`
+ );
+ }
+
+ return `${result.stdout || ''}${result.stderr || ''}`;
+}
+
+async function pathExists(candidate) {
+ try {
+ await access(candidate);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function parseTrailingJson(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()) {
+ if (output[start] !== '[' && output[start] !== '{') {
+ continue;
+ }
+ const stack = [];
+ let escaped = false;
+ let inString = false;
+ for (let index = start; 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 {
+ return JSON.parse(output.slice(start, index + 1));
+ } catch {
+ break;
+ }
+ }
+ }
+ }
+ }
+ throw new Error(`JSON document not found in command output:\n${output}`);
+}
+
+function packedTarballFilename(metadata) {
+ const entries = Array.isArray(metadata) ? metadata : Object.values(metadata);
+ if (entries.length !== 1 || typeof entries[0]?.filename !== 'string') {
+ throw new Error(
+ `Unexpected npm pack metadata:\n${JSON.stringify(metadata)}`
+ );
+ }
+ return entries[0].filename;
+}
+
+async function prepareTarball() {
+ if (process.env.PACKAGE_TARBALL) {
+ const suppliedPath = path.resolve(process.env.PACKAGE_TARBALL);
+ await access(suppliedPath);
+ return suppliedPath;
+ }
+
+ const packageSpec = process.env.PACKAGE_SPEC;
+ const metadata = parseTrailingJson(
+ run('npm', [
+ 'pack',
+ '--ignore-scripts',
+ '--json',
+ '--pack-destination',
+ temporaryDirectory,
+ ...(packageSpec ? [packageSpec] : []),
+ ])
+ );
+ return path.join(temporaryDirectory, packedTarballFilename(metadata));
+}
+
+function readPackedManifest(tarballPath) {
+ return JSON.parse(run('tar', ['-xOf', tarballPath, 'package/package.json']));
+}
+
+async function prepareRegistry040Tarball() {
+ const metadata = parseTrailingJson(
+ run('npm', [
+ 'pack',
+ '--ignore-scripts',
+ '--json',
+ '--pack-destination',
+ temporaryDirectory,
+ registry040TarballUrl,
+ ])
+ );
+ const tarballPath = path.join(
+ temporaryDirectory,
+ packedTarballFilename(metadata)
+ );
+ const integrity = `sha512-${createHash('sha512')
+ .update(await readFile(tarballPath))
+ .digest('base64')}`;
+ assert.equal(
+ integrity,
+ registry040Integrity,
+ 'The cross-version fixture must be the published registry 0.4.0 tarball'
+ );
+ const manifest = readPackedManifest(tarballPath);
+ assert.equal(manifest.name, 'react-native-bs-diff-patch');
+ assert.equal(manifest.version, '0.4.0');
+ assert.equal(manifest.exports['.'].browser, './web/index.mjs');
+ assert.equal(
+ Object.hasOwn(manifest.exports, './web'),
+ false,
+ 'Published 0.4.0 is intentionally tested through its root browser entry'
+ );
+ return tarballPath;
+}
+
+async function listFiles(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const results = [];
+ for (const entry of entries) {
+ const candidate = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...(await listFiles(candidate)));
+ } else {
+ results.push(candidate);
+ }
+ }
+ return results;
+}
+
+function fromBase64(value) {
+ return Buffer.from(value, 'base64');
+}
+
+function nativeCliSource() {
+ return [
+ '#include "bsdiff.h"',
+ '#include "bspatch.h"',
+ '#include ',
+ '#include ',
+ '',
+ 'int main(int argc, char **argv) {',
+ ' if (argc != 5) {',
+ ' fprintf(stderr, "usage: native-fixture \\n");',
+ ' return 64;',
+ ' }',
+ ' if (strcmp(argv[1], "diff") == 0)',
+ ' return bsDiffFile(argv[2], argv[3], argv[4]) == 0 ? 0 : 1;',
+ ' if (strcmp(argv[1], "patch") == 0)',
+ ' return bsPatchFile(argv[2], argv[4], argv[3]) == 0 ? 0 : 1;',
+ ' fprintf(stderr, "unknown operation: %s\\n", argv[1]);',
+ ' return 64;',
+ '}',
+ '',
+ ].join('\n');
+}
+
+async function buildNativeFixture() {
+ const fixtureDirectory = path.join(temporaryDirectory, 'native-fixture');
+ const sourcePath = path.join(fixtureDirectory, 'native-fixture.c');
+ const executablePath = path.join(fixtureDirectory, 'native-fixture');
+ await mkdir(fixtureDirectory, { recursive: true });
+ await writeFile(sourcePath, nativeCliSource());
+
+ const sources = [
+ 'cpp/bsdiff.c',
+ 'cpp/bspatch.c',
+ 'cpp/bspatch_streaming.c',
+ 'cpp/bzlib/blocksort.c',
+ 'cpp/bzlib/bzlib.c',
+ 'cpp/bzlib/compress.c',
+ 'cpp/bzlib/crctable.c',
+ 'cpp/bzlib/decompress.c',
+ 'cpp/bzlib/huffman.c',
+ 'cpp/bzlib/randtable.c',
+ ].map((source) => path.join(repositoryDirectory, source));
+ const featureTestMacro =
+ process.platform === 'darwin'
+ ? '-D_DARWIN_C_SOURCE'
+ : '-D_POSIX_C_SOURCE=200809L';
+
+ run('cc', [
+ '-std=c11',
+ featureTestMacro,
+ '-O2',
+ '-Wall',
+ '-Wextra',
+ '-Werror',
+ '-Wno-implicit-fallthrough',
+ '-Wno-unused-parameter',
+ '-I',
+ path.join(repositoryDirectory, 'cpp'),
+ sourcePath,
+ ...sources,
+ '-o',
+ executablePath,
+ ]);
+ return executablePath;
+}
+
+function createBrowserEntry({ importPath, includeToolkit, includeProgress }) {
+ const imports = ['diffBytes', 'inspectPatch', 'patchBytes', 'verifyPatch'];
+ if (includeToolkit) {
+ imports.push('startDiffBytes');
+ }
+ const lines = [`import { ${imports.join(', ')} } from '${importPath}';`];
+ if (includeToolkit) {
+ lines.push(
+ "import { canonicalJson, createPatchManifest } from 'react-native-bs-diff-patch/toolkit';"
+ );
+ }
+ lines.push(
+ 'const sdkWindow = window as typeof window & {',
+ ' __bsdiffSdkConsumer: Promise<{ patch: string; old: string; target: string; patchBytes: number; outputLimitCode: string | undefined }>;',
+ ' __bsdiffSdkApplyPatch: (patchBase64: string) => Promise;',
+ '};',
+ 'const encoder = new TextEncoder();',
+ "const oldData = encoder.encode('SDK consumer old payload\\n'.repeat(96));",
+ "const newData = encoder.encode('SDK consumer new payload\\n'.repeat(64) + 'additional verified content\\n'.repeat(32));",
+ 'const oldSnapshot = oldData.slice();',
+ 'function sameBytes(left: Uint8Array, right: Uint8Array): boolean {',
+ ' return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);',
+ '}',
+ 'function toBase64(value: Uint8Array): string {',
+ ' let result = "";',
+ ' for (let index = 0; index < value.length; index += 1) result += String.fromCharCode(value[index]!);',
+ ' return btoa(result);',
+ '}',
+ 'function fromBase64(value: string): Uint8Array {',
+ ' const decoded = atob(value);',
+ ' return Uint8Array.from(decoded, (character) => character.charCodeAt(0));',
+ '}',
+ 'async function expectError(operation: () => Promise): Promise {',
+ ' try { await operation(); return undefined; } catch (error: unknown) { return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : undefined; }',
+ '}',
+ 'async function run() {',
+ ...(includeProgress
+ ? [
+ " const progress: Array<{ operation: 'diff' | 'patch'; phase: 'reading' | 'processing' | 'writing'; progress: number }> = [];",
+ ' const patch = await diffBytes(oldData, newData, { onProgress: (event) => progress.push(event) });',
+ ]
+ : [' const patch = await diffBytes(oldData, newData);']),
+ ' const restored = await patchBytes(oldData, patch);',
+ ' const metadata = await inspectPatch(patch);',
+ ' const verified = await verifyPatch(oldData, patch, newData);',
+ ' const wrongBaseline = await verifyPatch(encoder.encode("wrong baseline"), patch, newData);',
+ ' const invalidHeader = await inspectPatch(new Uint8Array([66, 83, 68, 73, 70, 70, 52, 48]));',
+ ' const outputLimitCode = await expectError(() => patchBytes(oldData, patch, { maxOutputBytes: newData.byteLength - 1 }));',
+ ' const concurrent = await Promise.all([diffBytes(oldData, newData), diffBytes(oldData, newData)]);',
+ ` if (!sameBytes(restored, newData) || !sameBytes(oldData, oldSnapshot) || !verified.verified || wrongBaseline.verified || metadata.format !== "ENDSLEY/BSDIFF43" || !metadata.valid || invalidHeader.format !== "BSDIFF40" || invalidHeader.valid || outputLimitCode !== "ERESOURCE" || !sameBytes(concurrent[0], patch) || !sameBytes(concurrent[1], patch)${
+ includeProgress
+ ? ' || !progress.some((event) => event.phase === "processing" && event.progress > 0)'
+ : ''
+ }) {`,
+ ' throw new Error("SDK Web consumer assertions failed");',
+ ' }'
+ );
+ if (includeToolkit) {
+ lines.push(
+ ' const cancelledJob = startDiffBytes(oldData, newData);',
+ ' await cancelledJob.cancel();',
+ ' await cancelledJob.cancel();',
+ ' const cancelledCode = await expectError(() => cancelledJob.result);',
+ ' const nextResult = await startDiffBytes(oldData, newData).result;',
+ ' if (cancelledCode !== "EABORTED" || !sameBytes(nextResult, patch)) throw new Error("Task cancellation consumer assertions failed");',
+ ' const manifest = createPatchManifest({ baseline: { bytes: oldData.byteLength, sha256: "1".repeat(64) }, patch: { bytes: patch.byteLength, sha256: "2".repeat(64) }, target: { bytes: newData.byteLength, sha256: "3".repeat(64) } });',
+ ' if (canonicalJson({ b: 1, a: 2 }) !== "{\\"a\\":2,\\"b\\":1}" || manifest.version !== 1) throw new Error("Toolkit consumer assertions failed");'
+ );
+ }
+ lines.push(
+ ' return {',
+ ' patch: toBase64(patch),',
+ ' old: toBase64(oldData),',
+ ' target: toBase64(newData),',
+ ' patchBytes: patch.byteLength,',
+ ' outputLimitCode,',
+ ' };',
+ '}',
+ 'sdkWindow.__bsdiffSdkConsumer = run();',
+ 'sdkWindow.__bsdiffSdkApplyPatch = async (patchBase64) => {',
+ ' const restored = await patchBytes(oldData, fromBase64(patchBase64));',
+ ' return toBase64(restored);',
+ '};',
+ ''
+ );
+ return lines.join('\n');
+}
+
+async function writeConsumer({
+ name,
+ packageSpec,
+ importPath,
+ includeToolkit,
+ includeProgress,
+}) {
+ const directory = path.join(temporaryDirectory, name);
+ await mkdir(path.join(directory, 'src'), { recursive: true });
+ await writeFile(
+ path.join(directory, 'package.json'),
+ `${JSON.stringify(
+ {
+ name: `${name}-consumer`,
+ private: true,
+ type: 'module',
+ devDependencies: {
+ typescript: '5.8.3',
+ vite: '7.1.0',
+ },
+ },
+ null,
+ 2
+ )}\n`
+ );
+ await writeFile(
+ path.join(directory, 'index.html'),
+ 'SDK consumer \n'
+ );
+ await writeFile(
+ path.join(directory, 'vite.config.mjs'),
+ [
+ "import { defineConfig } from 'vite';",
+ 'export default defineConfig({',
+ " build: { target: 'es2022' },",
+ " worker: { format: 'es' },",
+ '});',
+ '',
+ ].join('\n')
+ );
+ await writeFile(
+ path.join(directory, 'src', 'main.ts'),
+ createBrowserEntry({ importPath, includeToolkit, includeProgress })
+ );
+ 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',
+ },
+ 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',
+ },
+ include: ['src/native-types.ts'],
+ },
+ null,
+ 2
+ )}\n`
+ );
+ }
+
+ run(
+ 'npm',
+ ['install', '--no-audit', '--no-fund', '--prefer-offline', packageSpec],
+ { cwd: directory }
+ );
+
+ assert.equal(
+ await pathExists(path.join(directory, 'node_modules/react-native')),
+ false,
+ `${name} must not install React Native`
+ );
+ assert.equal(
+ await pathExists(path.join(directory, 'node_modules/react')),
+ false,
+ `${name} must not install React`
+ );
+ const installedTree = JSON.parse(
+ run('npm', ['ls', '--all', '--omit=dev', '--json'], { cwd: directory })
+ );
+ assert.equal(
+ Object.hasOwn(installedTree.dependencies || {}, 'react-native'),
+ false,
+ `${name} dependency tree must not contain React Native`
+ );
+
+ 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 buildOutput = run(
+ process.execPath,
+ [path.join(directory, 'node_modules/vite/bin/vite.js'), 'build'],
+ { cwd: directory }
+ );
+
+ assert.equal(
+ await pathExists(path.join(directory, 'dist', 'index.html')),
+ true,
+ `${name} Vite consumer did not build`
+ );
+ return { buildOutput, directory };
+}
+
+async function serveDirectory(directory) {
+ const mimeTypes = new Map([
+ ['.css', 'text/css; charset=utf-8'],
+ ['.html', 'text/html; charset=utf-8'],
+ ['.js', 'text/javascript; charset=utf-8'],
+ ['.mjs', 'text/javascript; charset=utf-8'],
+ ['.wasm', 'application/wasm'],
+ ]);
+ const root = path.resolve(directory);
+ const server = createServer(async (request, response) => {
+ try {
+ const pathname = decodeURIComponent(
+ new URL(request.url || '/', 'http://127.0.0.1').pathname
+ );
+ const requestedPath = path.resolve(root, `.${pathname}`);
+ if (
+ requestedPath !== root &&
+ !requestedPath.startsWith(`${root}${path.sep}`)
+ ) {
+ response.writeHead(403).end('Forbidden');
+ return;
+ }
+ const filePath =
+ pathname === '/' ? path.join(root, 'index.html') : requestedPath;
+ if (!(await stat(filePath)).isFile()) {
+ response.writeHead(404).end('Not Found');
+ return;
+ }
+ response.writeHead(200, {
+ 'Content-Security-Policy': browserCsp,
+ 'Content-Type':
+ mimeTypes.get(path.extname(filePath)) || 'application/octet-stream',
+ });
+ response.end(await readFile(filePath));
+ } catch {
+ response.writeHead(404).end('Not Found');
+ }
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return server;
+}
+
+async function runBrowserConsumer(browser, directory, name) {
+ const server = await serveDirectory(path.join(directory, 'dist'));
+ const address = server.address();
+ const origin = `http://127.0.0.1:${address.port}`;
+ const requests = [];
+ const blockedRequests = [];
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ const url = request.url();
+ if (url.startsWith(origin)) {
+ requests.push({ type: request.resourceType(), url });
+ void request.continue();
+ return;
+ }
+ blockedRequests.push(url);
+ void request.abort();
+ });
+
+ try {
+ const documentResponse = await page.goto(origin, {
+ waitUntil: 'networkidle0',
+ });
+ assert.equal(
+ documentResponse?.headers()['content-security-policy'],
+ browserCsp,
+ `${name} did not receive the strict production CSP`
+ );
+ const result = await page.evaluate(async () => window.__bsdiffSdkConsumer);
+ assert.equal(result.outputLimitCode, 'ERESOURCE');
+ assert.ok(result.patchBytes > 24, `${name} did not produce a patch`);
+ assert.equal(
+ blockedRequests.length,
+ 0,
+ `${name} tried to access a non-local resource: ${blockedRequests.join(
+ ', '
+ )}`
+ );
+ assert.ok(
+ requests.length >= 3,
+ `${name} did not load a Worker resource from the production bundle`
+ );
+ return {
+ ...result,
+ resourceUrls: requests.map(({ url }) => url),
+ async applyPatch(patchBase64) {
+ return page.evaluate(
+ (encoded) => window.__bsdiffSdkApplyPatch(encoded),
+ patchBase64
+ );
+ },
+ close: async () => {
+ await page.close();
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve()))
+ );
+ },
+ };
+ } catch (error) {
+ await page.close();
+ await new Promise((resolve, reject) =>
+ server.close((closeError) =>
+ closeError ? reject(closeError) : resolve()
+ )
+ );
+ throw error;
+ }
+}
+
+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',
+ ]) {
+ assert.match(
+ tarEntries,
+ new RegExp(`^${entry}$`, 'm'),
+ `${entry} is absent`
+ );
+ }
+ assert.doesNotMatch(tarEntries, /^package\/web\/progress_bridge\.c$/m);
+}
+
+async function assertManifestContract(consumerDirectory, expectedVersion) {
+ const manifest = JSON.parse(
+ await readFile(
+ path.join(
+ consumerDirectory,
+ 'node_modules/react-native-bs-diff-patch/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');
+ 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,
+ 'The ESM-only /toolkit entry must not claim CommonJS support'
+ );
+}
+
+async function assertNoNodeRuntimeInBuild(directory, buildOutput) {
+ assert.doesNotMatch(
+ buildOutput,
+ /externalized for browser compatibility/i,
+ 'Vite externalized a Node runtime import from the formal /web entry'
+ );
+ const files = await listFiles(path.join(directory, 'dist'));
+ const source = await Promise.all(
+ files
+ .filter((filename) => /\.(?:js|mjs)$/i.test(filename))
+ .map((filename) => readFile(filename, 'utf8'))
+ );
+ assert.ok(
+ source.some((value) => value.includes('new Worker')),
+ 'The production bundle did not retain a Worker constructor'
+ );
+ assert.equal(
+ source.some((value) =>
+ /['"]node:|NODEFS|require\(['"](?:fs|path|node:)/.test(value)
+ ),
+ false,
+ 'The production /web bundle still contains a Node runtime branch'
+ );
+}
+
+try {
+ const executablePath = chromeCandidates.find((candidate) =>
+ existsSync(candidate)
+ );
+ if (!executablePath) {
+ throw new Error(
+ 'Chrome executable not found; set CHROME_PATH to run the SDK consumer test'
+ );
+ }
+
+ const tarballPath = await prepareTarball();
+ await assertPackContract(tarballPath);
+ const packedManifest = readPackedManifest(tarballPath);
+ assert.equal(packedManifest.name, 'react-native-bs-diff-patch');
+ const tarballIntegrity = createHash('sha512')
+ .update(await readFile(tarballPath))
+ .digest('base64');
+
+ const current = await writeConsumer({
+ name: 'current-vite',
+ packageSpec: tarballPath,
+ importPath: 'react-native-bs-diff-patch/web',
+ includeToolkit: true,
+ includeProgress: true,
+ });
+ await assertManifestContract(current.directory, packedManifest.version);
+ await assertNoNodeRuntimeInBuild(current.directory, current.buildOutput);
+
+ const registryTarballPath = await prepareRegistry040Tarball();
+ const registry = await writeConsumer({
+ name: 'registry-v040-vite',
+ packageSpec: registryTarballPath,
+ importPath: 'react-native-bs-diff-patch',
+ includeToolkit: false,
+ includeProgress: false,
+ });
+
+ const browser = await puppeteer.launch({
+ executablePath,
+ headless: true,
+ args: [
+ '--disable-background-networking',
+ '--disable-dev-shm-usage',
+ '--host-resolver-rules=MAP * ~NOTFOUND,EXCLUDE 127.0.0.1',
+ ],
+ });
+ let currentBrowser;
+ let registryBrowser;
+ try {
+ currentBrowser = await runBrowserConsumer(
+ browser,
+ current.directory,
+ `${packedManifest.version} /web`
+ );
+ registryBrowser = await runBrowserConsumer(
+ browser,
+ registry.directory,
+ 'registry 0.4.0 root browser'
+ );
+
+ assert.deepEqual(
+ fromBase64(await currentBrowser.applyPatch(registryBrowser.patch)),
+ fromBase64(currentBrowser.target),
+ `${packedManifest.version} /web 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`
+ );
+
+ const nativeCli = await buildNativeFixture();
+ const fixtureDirectory = path.join(temporaryDirectory, 'native-cross');
+ const oldPath = path.join(fixtureDirectory, 'old.bin');
+ const targetPath = path.join(fixtureDirectory, 'target.bin');
+ const currentPatchPath = path.join(fixtureDirectory, 'current.patch');
+ const registryPatchPath = path.join(fixtureDirectory, 'registry.patch');
+ const nativePatchPath = path.join(fixtureDirectory, 'native.patch');
+ const restoredCurrentPath = path.join(
+ fixtureDirectory,
+ 'restored-current.bin'
+ );
+ const restoredRegistryPath = path.join(
+ fixtureDirectory,
+ 'restored-registry.bin'
+ );
+ await mkdir(fixtureDirectory, { recursive: true });
+ await Promise.all([
+ writeFile(oldPath, fromBase64(currentBrowser.old)),
+ writeFile(targetPath, fromBase64(currentBrowser.target)),
+ writeFile(currentPatchPath, fromBase64(currentBrowser.patch)),
+ writeFile(registryPatchPath, fromBase64(registryBrowser.patch)),
+ ]);
+ run(nativeCli, ['patch', oldPath, currentPatchPath, restoredCurrentPath]);
+ run(nativeCli, ['patch', oldPath, registryPatchPath, restoredRegistryPath]);
+ assert.deepEqual(
+ await readFile(restoredCurrentPath),
+ await readFile(targetPath)
+ );
+ assert.deepEqual(
+ await readFile(restoredRegistryPath),
+ await readFile(targetPath)
+ );
+ run(nativeCli, ['diff', oldPath, targetPath, nativePatchPath]);
+ assert.deepEqual(
+ fromBase64(
+ await currentBrowser.applyPatch(
+ (await readFile(nativePatchPath)).toString('base64')
+ )
+ ),
+ await readFile(targetPath),
+ `${packedManifest.version} /web did not restore a native-generated patch`
+ );
+ assert.deepEqual(
+ fromBase64(
+ await registryBrowser.applyPatch(
+ (await readFile(nativePatchPath)).toString('base64')
+ )
+ ),
+ await readFile(targetPath),
+ 'registry 0.4.0 did not restore a native-generated patch'
+ );
+ } finally {
+ await registryBrowser?.close();
+ await currentBrowser?.close();
+ await browser.close();
+ }
+
+ console.log(
+ `SDK consumers passed: version=${
+ packedManifest.version
+ } tarball=${tarballPath} sha512-${tarballIntegrity} registry040=${registry040Integrity} retained=${
+ keepTemporaryDirectory ? temporaryDirectory : 'no'
+ }`
+ );
+ console.log(
+ `SDK browser evidence: csp=${browserCsp} currentResources=${JSON.stringify(
+ currentBrowser.resourceUrls
+ )} registryResources=${JSON.stringify(registryBrowser.resourceUrls)}`
+ );
+} finally {
+ if (keepTemporaryDirectory) {
+ console.log(`SDK consumer artifacts retained at ${temporaryDirectory}`);
+ } else {
+ await rm(temporaryDirectory, { force: true, recursive: true });
+ }
+}
diff --git a/scripts/test-site-browser.mjs b/scripts/test-site-browser.mjs
index 15004b7..d95b821 100644
--- a/scripts/test-site-browser.mjs
+++ b/scripts/test-site-browser.mjs
@@ -228,8 +228,8 @@ try {
const generatedManifest = JSON.parse(
await page.$eval('#manifest-output', (element) => element.textContent || '')
);
- assert.equal(generatedManifest.manifestVersion, 1);
- assert.equal(generatedManifest.patchFormat, 'ENDSLEY/BSDIFF43');
+ assert.equal(generatedManifest.version, 1);
+ assert.equal(generatedManifest.format, 'ENDSLEY/BSDIFF43');
assert.equal(generatedManifest.target.bytes, fixtures.newBytes.length);
assert.equal(generatedManifest.target.sha256.length, 64);
assert.equal(generatedManifest.patch.sha256.length, 64);
@@ -299,6 +299,101 @@ try {
'English'
);
+ await page.setViewport({ width: 1280, height: 900, deviceScaleFactor: 1 });
+ await page.goto(`${baseUrl}/planner/`, { waitUntil: 'networkidle0' });
+ await page.waitForSelector('#planner-runtime-state[data-state="ready"]');
+ const plannerFixtures = {
+ baselineOne: [...new TextEncoder().encode('A'.repeat(8192))],
+ baselineTwo: [...new TextEncoder().encode(`${'A'.repeat(8190)}CC`)],
+ target: [...new TextEncoder().encode(`${'A'.repeat(8191)}B`)],
+ };
+ await selectFile(
+ '#planner-target-file',
+ plannerFixtures.target,
+ 'release-v3.bin'
+ );
+ await page.evaluate(
+ ({ first, second }) => {
+ const input = document.querySelector('#planner-baseline-files');
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File([new Uint8Array(first)], 'release- .bin', {
+ type: 'application/octet-stream',
+ })
+ );
+ transfer.items.add(
+ new File([new Uint8Array(second)], 'release-v2.bin', {
+ type: 'application/octet-stream',
+ })
+ );
+ input.files = transfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ },
+ {
+ first: plannerFixtures.baselineOne,
+ second: plannerFixtures.baselineTwo,
+ }
+ );
+ await page.click('#planner-run');
+ await page.waitForSelector('#planner-status[data-state="success"]', {
+ timeout: 30_000,
+ });
+ assert.equal(
+ await page.$eval(
+ '#planner-baseline-count',
+ (element) => element.textContent
+ ),
+ '2'
+ );
+ assert.equal(
+ await page.$eval('#planner-patch-count', (element) => element.textContent),
+ '2'
+ );
+ assert.equal(
+ await page.$$eval('#planner-matrix tr', (rows) => rows.length),
+ 2
+ );
+ assert.equal(
+ await page.$eval(
+ '#planner-matrix tr:first-child strong',
+ (element) => element.textContent
+ ),
+ 'release- .bin'
+ );
+ assert.equal(
+ await page.$$eval('#planner-matrix img', (images) => images.length),
+ 0
+ );
+ assert.equal(
+ await page.$eval(
+ '#planner-download-manifest',
+ (element) => element.disabled
+ ),
+ false
+ );
+
+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 });
+ await page.reload({ waitUntil: 'networkidle0' });
+ const plannerMobile = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ }));
+ assert.ok(
+ plannerMobile.scrollWidth <= plannerMobile.clientWidth + 1,
+ `planner mobile layout overflows by ${
+ plannerMobile.scrollWidth - plannerMobile.clientWidth
+ }px`
+ );
+
+ await page.goto(`${baseUrl}/zh-CN/planner/`, {
+ waitUntil: 'networkidle0',
+ });
+ assert.equal(await page.$eval('html', (element) => element.lang), 'zh-CN');
+ assert.match(
+ await page.$eval('h1', (element) => element.textContent || ''),
+ /规划一次发布/
+ );
+
await page.goto(`${baseUrl}/docs/api-reference/`, {
waitUntil: 'networkidle0',
});
diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs
index 6364ef8..a51aa1a 100644
--- a/scripts/test-site.mjs
+++ b/scripts/test-site.mjs
@@ -23,32 +23,45 @@ const requiredFiles = [
'assets/site.js',
'assets/playground.js',
'assets/tools.js',
+ 'assets/planner.js',
'assets/social-preview.png',
'web/index.mjs',
'web/worker.mjs',
'web/operations.mjs',
'web/bsdiffpatch.mjs',
+ 'web/bsdiffpatch.browser.mjs',
+ 'web/operations.browser.mjs',
+ 'web/operation-runtime.mjs',
+ 'web/worker.browser.mjs',
+ 'docs/web-sdk/index.html',
+ 'docs/zh-CN/web-sdk/index.html',
'zh-CN/index.html',
'tools/index.html',
'zh-CN/tools/index.html',
+ 'planner/index.html',
+ 'zh-CN/planner/index.html',
+ 'toolkit/index.mjs',
+ 'toolkit/index.d.ts',
'docs/index.html',
'docs/getting-started/index.html',
'docs/api-reference/index.html',
'docs/recipes/index.html',
+ 'docs/verified-delta-pipeline/index.html',
'docs/platform-support/index.html',
'docs/architecture/index.html',
'docs/native-operations-v03/index.html',
- 'docs/large-files-v04/index.html',
+ 'docs/large-files-roadmap/index.html',
'docs/troubleshooting/index.html',
'docs/development/index.html',
'docs/zh-CN/index.html',
'docs/zh-CN/getting-started/index.html',
'docs/zh-CN/api-reference/index.html',
'docs/zh-CN/recipes/index.html',
+ 'docs/zh-CN/verified-delta-pipeline/index.html',
'docs/zh-CN/platform-support/index.html',
'docs/zh-CN/architecture/index.html',
'docs/zh-CN/native-operations-v03/index.html',
- 'docs/zh-CN/large-files-v04/index.html',
+ 'docs/zh-CN/large-files-roadmap/index.html',
'docs/zh-CN/troubleshooting/index.html',
'docs/zh-CN/development/index.html',
];
@@ -76,6 +89,10 @@ assert.match(
await readFile(path.join(outputDirectory, 'sitemap.xml'), 'utf8'),
/https:\/\/bs-dff-patch\.corerobin\.com\/zh-CN\/tools\//
);
+assert.match(
+ await readFile(path.join(outputDirectory, 'sitemap.xml'), 'utf8'),
+ /https:\/\/bs-dff-patch\.corerobin\.com\/planner\//
+);
async function htmlFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
@@ -258,6 +275,26 @@ assert.match(chineseToolsPage, /完整性清单/);
assert.match(chineseToolsPage, /传输节省计算器/);
assert.match(chineseToolsPage, /错误码诊断器/);
assert.match(chineseToolsPage, /href="\/tools\/"\s+hreflang="en"/);
+
+const plannerPage = await readFile(
+ path.join(outputDirectory, 'planner/index.html'),
+ 'utf8'
+);
+assert.match(plannerPage, //);
+assert.match(plannerPage, /id="planner-baseline-files"/);
+assert.match(plannerPage, /id="planner-target-file"/);
+assert.match(plannerPage, /id="planner-max-ratio"/);
+assert.match(plannerPage, /id="planner-matrix"/);
+assert.match(plannerPage, /assets\/planner\.js/);
+assert.match(plannerPage, /Release Planner/);
+
+const chinesePlannerPage = await readFile(
+ path.join(outputDirectory, 'zh-CN/planner/index.html'),
+ 'utf8'
+);
+assert.match(chinesePlannerPage, //);
+assert.match(chinesePlannerPage, /多基线补丁矩阵/);
+assert.match(chinesePlannerPage, /href="\/planner\/"\s+hreflang="en"/);
assert.doesNotMatch(chineseToolsPage, /\{\{[A-Z0-9_]+\}\}/);
function pngDimensions(buffer) {
diff --git a/scripts/test-toolkit.mjs b/scripts/test-toolkit.mjs
new file mode 100644
index 0000000..9d8bb07
--- /dev/null
+++ b/scripts/test-toolkit.mjs
@@ -0,0 +1,322 @@
+import assert from 'node:assert/strict';
+
+import {
+ canonicalJson,
+ classifyPatchError,
+ createPatchBundle,
+ createPatchManifest,
+ inspectPatchHeader,
+ PatchToolkitError,
+ selectPatch,
+ signingPayload,
+ validatePatchBundle,
+ validatePatchManifest,
+} from '../toolkit/index.mjs';
+
+const hashes = {
+ baseline: '1'.repeat(64),
+ patch: '2'.repeat(64),
+ target: '3'.repeat(64),
+};
+const manifest = createPatchManifest({
+ baseline: { bytes: 1000, sha256: hashes.baseline },
+ patch: { bytes: 120, sha256: hashes.patch, url: 'release.patch' },
+ releaseId: 'v5',
+ signature: {
+ algorithm: 'ed25519',
+ detached: true,
+ keyId: 'release-2026',
+ },
+ target: { bytes: 1100, sha256: hashes.target },
+});
+
+assert.deepEqual(validatePatchManifest(manifest), manifest);
+assert.equal(
+ canonicalJson({ z: 1, a: { y: true, b: 'value' } }),
+ '{"a":{"b":"value","y":true},"z":1}'
+);
+assert.ok(!signingPayload(manifest).includes('signature'));
+assert.deepEqual(
+ classifyPatchError(
+ Object.assign(new Error('too large'), { code: 'ERESOURCE' })
+ ),
+ {
+ category: 'RESOURCE',
+ code: 'ERESOURCE',
+ message: 'too large',
+ retryable: false,
+ }
+);
+
+const bundle = createPatchBundle({
+ full: { bytes: 1100, sha256: hashes.target, url: 'full.bin' },
+ patches: [
+ {
+ baseline: manifest.baseline,
+ declaredTargetBytes: '1100',
+ format: 'ENDSLEY/BSDIFF43',
+ patch: manifest.patch,
+ },
+ ],
+ target: manifest.target,
+});
+assert.deepEqual(validatePatchBundle(bundle), bundle);
+assert.equal(
+ selectPatch(bundle, {
+ baselineSha256: hashes.baseline,
+ maxPatchRatio: 0.2,
+ }).strategy,
+ 'patch'
+);
+assert.deepEqual(selectPatch(bundle, { baselineSha256: '4'.repeat(64) }), {
+ artifact: bundle.full,
+ reason: 'BASELINE_NOT_FOUND',
+ strategy: 'full',
+});
+assert.equal(
+ selectPatch(bundle, {
+ baselineSha256: hashes.baseline,
+ maxPatchRatio: 0.05,
+ }).reason,
+ 'PATCH_RATIO_EXCEEDED'
+);
+assert.throws(
+ () => validatePatchManifest({ ...manifest, version: 2 }),
+ (error) => error && error.code === 'EINVALID_MANIFEST'
+);
+assert.throws(
+ () =>
+ validatePatchBundle({
+ ...bundle,
+ patches: [
+ {
+ ...bundle.patches[0],
+ declaredTargetBytes: '1099',
+ },
+ ],
+ }),
+ (error) => error && error.code === 'EINVALID_MANIFEST'
+);
+
+// Validation normalizes JSON metadata only; unknown fields are not trusted.
+const decorated = structuredClone(manifest);
+decorated.extra = 'not part of the contract';
+decorated.baseline.extra = true;
+decorated.signature.extra = 'not a signature';
+assert.deepEqual(validatePatchManifest(decorated), manifest);
+assert.equal(
+ decorated.baseline.extra,
+ true,
+ 'validation must not mutate input'
+);
+assert.deepEqual(
+ validatePatchManifest(JSON.parse(JSON.stringify(manifest))),
+ manifest
+);
+assert.deepEqual(
+ validatePatchBundle(JSON.parse(JSON.stringify(bundle))),
+ bundle
+);
+const decoratedBundle = structuredClone(bundle);
+decoratedBundle.unknown = true;
+decoratedBundle.patches[0].unknown = true;
+assert.deepEqual(validatePatchBundle(decoratedBundle), bundle);
+
+function throwsCode(callback, code) {
+ assert.throws(
+ callback,
+ (error) => error instanceof PatchToolkitError && error.code === code
+ );
+}
+
+for (const invalidBytes of [
+ -1,
+ 1.5,
+ NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER + 1,
+]) {
+ throwsCode(
+ () =>
+ validatePatchManifest({
+ ...manifest,
+ baseline: { ...manifest.baseline, bytes: invalidBytes },
+ }),
+ 'EINVALID_MANIFEST'
+ );
+}
+for (const invalidHash of ['', 'a'.repeat(63), 'g'.repeat(64), 12, null]) {
+ throwsCode(
+ () =>
+ validatePatchManifest({
+ ...manifest,
+ target: { ...manifest.target, sha256: invalidHash },
+ }),
+ 'EINVALID_MANIFEST'
+ );
+}
+for (const signature of [
+ null,
+ { algorithm: 'ed25519', keyId: 'key', detached: false },
+ { algorithm: '', keyId: 'key', detached: true },
+]) {
+ throwsCode(
+ () => validatePatchManifest({ ...manifest, signature }),
+ 'EINVALID_MANIFEST'
+ );
+}
+throwsCode(
+ () => validatePatchBundle({ ...bundle, full: { ...bundle.full, bytes: 5 } }),
+ 'EINVALID_MANIFEST'
+);
+throwsCode(
+ () => validatePatchBundle({ ...bundle, patches: null }),
+ 'EINVALID_MANIFEST'
+);
+
+// First baseline match wins. A later, smaller candidate is deliberately not selected.
+const orderedBundle = createPatchBundle({
+ ...bundle,
+ patches: [
+ bundle.patches[0],
+ { ...bundle.patches[0], patch: { ...manifest.patch, bytes: 1 } },
+ ],
+});
+assert.equal(
+ selectPatch(orderedBundle, { baselineSha256: hashes.baseline }).artifact
+ .bytes,
+ 120
+);
+assert.equal(
+ selectPatch(orderedBundle, {
+ baselineSha256: hashes.baseline,
+ maxPatchBytes: 119,
+ }).reason,
+ 'PATCH_BYTES_EXCEEDED'
+);
+assert.equal(
+ selectPatch(bundle, {
+ baselineSha256: hashes.baseline,
+ maxPatchBytes: 120,
+ maxPatchRatio: 120 / 1100,
+ }).strategy,
+ 'patch'
+);
+assert.equal(
+ selectPatch(bundle, { baselineSha256: hashes.baseline, maxPatchBytes: 0 })
+ .strategy,
+ 'full'
+);
+for (const baselineSha256 of [hashes.baseline, 'f'.repeat(64)]) {
+ for (const maxPatchBytes of [
+ -1,
+ 1.5,
+ NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER + 1,
+ ]) {
+ throwsCode(
+ () => selectPatch(bundle, { baselineSha256, maxPatchBytes }),
+ 'EINVAL'
+ );
+ }
+ for (const maxPatchRatio of [-1, 1.01, NaN, Infinity]) {
+ throwsCode(
+ () => selectPatch(bundle, { baselineSha256, maxPatchRatio }),
+ 'EINVAL'
+ );
+ }
+}
+throwsCode(() => selectPatch(bundle, { baselineSha256: 'wrong' }), 'EINVAL');
+
+const cyclicArray = [];
+cyclicArray.push(cyclicArray);
+const cyclicObject = {};
+cyclicObject.self = cyclicObject;
+for (const invalid of [
+ cyclicArray,
+ cyclicObject,
+ { a: Infinity },
+ [undefined],
+]) {
+ throwsCode(() => canonicalJson(invalid), 'EINVALID_MANIFEST');
+}
+const shared = { b: 2 };
+assert.equal(canonicalJson([shared, shared]), '[{"b":2},{"b":2}]');
+assert.equal(
+ canonicalJson(JSON.parse('{"__proto__":{"x":1},"a":2}')),
+ '{"__proto__":{"x":1},"a":2}'
+);
+assert.equal(
+ signingPayload({
+ ...manifest,
+ signature: { ...manifest.signature, keyId: 'different-key' },
+ }),
+ signingPayload(manifest)
+);
+
+const validHeader = new Uint8Array(24);
+validHeader.set(new TextEncoder().encode('ENDSLEY/BSDIFF43'));
+validHeader[16] = 5;
+assert.deepEqual(inspectPatchHeader(validHeader, 100), {
+ declaredTargetBytes: '5',
+ format: 'ENDSLEY/BSDIFF43',
+ headerBytes: 24,
+ patchBytes: 100,
+ payloadBytes: 76,
+ valid: true,
+});
+assert.equal(
+ inspectPatchHeader(validHeader).valid,
+ true,
+ 'a header alone does not validate compressed payload'
+);
+for (let length = 0; length < 24; length += 1) {
+ assert.equal(inspectPatchHeader(validHeader.slice(0, length)).valid, false);
+}
+assert.equal(
+ inspectPatchHeader(new TextEncoder().encode('BSDIFF40')).issue,
+ 'LEGACY_FORMAT'
+);
+assert.equal(inspectPatchHeader(new Uint8Array(24)).issue, 'INVALID_MAGIC');
+const negativeHeader = validHeader.slice();
+negativeHeader[23] = 0x80;
+assert.equal(inspectPatchHeader(negativeHeader).issue, 'INVALID_TARGET_SIZE');
+const largeHeader = validHeader.slice();
+largeHeader.fill(0xff, 16);
+largeHeader[23] = 0x7f;
+assert.equal(
+ inspectPatchHeader(largeHeader).declaredTargetBytes,
+ '9223372036854775807'
+);
+for (const input of [null, undefined, [], new ArrayBuffer(24)]) {
+ throwsCode(() => inspectPatchHeader(input), 'EINVAL');
+}
+for (const length of [
+ -1,
+ 23,
+ 24.5,
+ NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER + 1,
+]) {
+ throwsCode(() => inspectPatchHeader(validHeader, length), 'EINVAL');
+}
+for (const [code, category] of Object.entries({
+ EABORTED: 'ABORTED',
+ ERESOURCE: 'RESOURCE',
+ EINVAL: 'INVALID_ARGUMENT',
+ EPATCH: 'INVALID_PATCH',
+ ELEGACYFORMAT: 'INVALID_PATCH',
+ ETARGETMISMATCH: 'VERIFICATION',
+ EDESTEXISTS: 'DESTINATION',
+ EUNSUPPORTED: 'UNSUPPORTED',
+ EWEBASSEMBLY: 'RUNTIME',
+})) {
+ assert.equal(classifyPatchError({ code }).category, category);
+}
+assert.equal(classifyPatchError(null).code, 'EUNSPECIFIED');
+
+console.log(
+ 'Toolkit manifest/bundle, selection, canonical payload, error and header boundary tests passed'
+);
diff --git a/scripts/test-web-browser.mjs b/scripts/test-web-browser.mjs
index ce37723..29ca24c 100644
--- a/scripts/test-web-browser.mjs
+++ b/scripts/test-web-browser.mjs
@@ -86,16 +86,25 @@ try {
inputsPreserved: true,
inputLimitErrorCode: 'ERESOURCE',
invalidInputErrorCode: 'EINVAL',
+ invalidLimitErrorCode: 'EINVAL',
+ jobProgress: result.jobProgress,
+ jobRestoredMatches: true,
metadataFormat: 'ENDSLEY/BSDIFF43',
mismatchVerified: false,
+ nativeOutputLimitErrorCode: 'ERESOURCE',
outputLimitErrorCode: 'ERESOURCE',
+ overflowLimitErrorCode: 'EINVAL',
patchLength: result.patchLength,
pathApiErrorCode: 'EUNSUPPORTED',
restoredMatches: true,
sharedSurvivedAbort: true,
+ truthfulProgress: result.truthfulProgress,
verificationPassed: true,
+ zeroOutputLimitErrorCode: 'ERESOURCE',
});
assert.ok(result.patchLength > 24);
+ assert.ok(result.truthfulProgress > 3);
+ assert.ok(result.jobProgress > 3);
console.log('Browser Web Worker diff/patch round trip passed');
} finally {
await browser.close();
diff --git a/scripts/test-web-metro.mjs b/scripts/test-web-metro.mjs
index 01f9bd4..98839a8 100644
--- a/scripts/test-web-metro.mjs
+++ b/scripts/test-web-metro.mjs
@@ -48,8 +48,19 @@ if (result.status !== 0) {
try {
const bundle = await readFile(bundlePath, 'utf8');
+ const workerSource = await readFile(
+ path.join(repositoryDirectory, 'web/worker.browser.mjs'),
+ 'utf8'
+ );
+ const operationsSource = await readFile(
+ path.join(repositoryDirectory, 'web/operations.browser.mjs'),
+ 'utf8'
+ );
assert.match(bundle, /Web Workers are required/);
- assert.match(bundle, /worker\.mjs/);
+ assert.match(bundle, /worker\.browser\.mjs/);
+ assert.doesNotMatch(bundle, /NODEFS/);
+ assert.match(workerSource, /operations\.browser\.mjs/);
+ assert.match(operationsSource, /bsdiffpatch\.browser\.mjs/);
assert.doesNotMatch(bundle, /diffBytes is only available on Web/);
assert.doesNotMatch(bundle, /NativeBsDiffPatch/);
console.log('Metro selected the React Native Web entry');
diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs
index 7d967cf..7f97836 100644
--- a/scripts/test-web.mjs
+++ b/scripts/test-web.mjs
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { runOperation } from '../web/operations.mjs';
+import { classifyRuntimeErrorCode, runOperation } from '../web/operations.mjs';
import { inspectPatch } from '../web/index.mjs';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
@@ -20,7 +20,30 @@ const newData = encoder.encode(
'hello from the new file\n'.repeat(96) + 'web round trip\n'.repeat(32)
);
-const patchData = await runOperation('diff', oldData, newData);
+assert.equal(
+ classifyRuntimeErrorCode(
+ new WebAssembly.RuntimeError('memory access out of bounds'),
+ 'memory access out of bounds'
+ ),
+ 'ERESOURCE',
+ 'WebAssembly OOM failures should use the portable resource code'
+);
+
+const progressEvents = [];
+const patchData = await runOperation('diff', oldData, newData, {
+ onProgress: (event) => progressEvents.push(event),
+});
+assert.ok(
+ progressEvents.some(
+ (event) => event.phase === 'processing' && event.progress > 0
+ ),
+ 'WebAssembly should expose real processing checkpoints'
+);
+assert.deepEqual(progressEvents.at(-1), {
+ operation: 'diff',
+ phase: 'writing',
+ progress: 1,
+});
assert.ok(patchData.byteLength > 24, 'diff should produce a non-empty patch');
assert.equal(
new TextDecoder().decode(patchData.subarray(0, 16)),
@@ -43,6 +66,15 @@ assert.deepEqual(metadata, {
payloadBytes: patchData.byteLength - 24,
valid: true,
});
+const patchBlob = new Blob([patchData]);
+patchBlob.arrayBuffer = async () => {
+ throw new Error('inspectPatch must not read the complete Blob');
+};
+assert.deepEqual(
+ await inspectPatch(patchBlob),
+ metadata,
+ 'Blob inspection should read only the 24-byte patch header'
+);
assert.deepEqual(await inspectPatch(new Uint8Array([1, 2, 3])), {
declaredTargetBytes: null,
format: 'UNKNOWN',
@@ -75,8 +107,8 @@ await assert.rejects(
await assert.rejects(
runOperation('patch', oldData, new Uint8Array([1, 2, 3])),
- (error) => error && error.code === 'EWEBASSEMBLY',
- 'corrupt patches should reject with a WebAssembly error'
+ (error) => error && error.code === 'EPATCH',
+ 'corrupt patch headers should reject with the portable patch error'
);
assert.deepEqual(
@@ -90,4 +122,228 @@ assert.deepEqual(
'Web should apply the patch shared with Android and iOS'
);
+class MockWorker {
+ static instances = [];
+
+ constructor(url, options) {
+ this.url = url;
+ this.options = options;
+ this.messages = [];
+ this.terminated = false;
+ MockWorker.instances.push(this);
+ }
+
+ postMessage(...arguments_) {
+ this.messages.push(arguments_);
+ }
+
+ terminate() {
+ this.terminated = true;
+ }
+
+ emitMessage(data) {
+ this.onmessage?.({ data });
+ }
+
+ emitError(message = 'synthetic worker failure') {
+ this.onerror?.({ message });
+ }
+
+ emitMessageError() {
+ this.onmessageerror?.({});
+ }
+}
+
+function requestFor(worker, index = 0) {
+ return worker.messages[index][0];
+}
+
+function resultFor(worker, output, index = 0) {
+ worker.emitMessage({
+ id: requestFor(worker, index).id,
+ type: 'result',
+ ok: true,
+ output,
+ });
+}
+
+async function waitFor(check, message) {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ const value = check();
+ if (value) return value;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ assert.fail(message);
+}
+
+const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Worker');
+Object.defineProperty(globalThis, 'Worker', {
+ configurable: true,
+ value: MockWorker,
+ writable: true,
+});
+
+try {
+ const webApi = await import(
+ new URL(
+ `../web/index.mjs?worker-lifecycle=${Date.now()}`,
+ import.meta.url
+ ).href
+ );
+ const workerOld = new Uint8Array([1, 2, 3]);
+ const workerInput = new Uint8Array([4, 5, 6]);
+ const workerOldSnapshot = workerOld.slice();
+ const workerInputSnapshot = workerInput.slice();
+
+ const firstRequest = webApi.diffBytes(workerOld, workerInput);
+ const firstWorker = MockWorker.instances.at(-1);
+ assert.equal(firstWorker.messages.length, 1);
+ assert.equal(
+ firstWorker.messages[0].length,
+ 1,
+ 'Web byte inputs must not be transferred to the Worker'
+ );
+ assert.deepEqual(workerOld, workerOldSnapshot);
+ assert.deepEqual(workerInput, workerInputSnapshot);
+ resultFor(firstWorker, new Uint8Array([7, 8, 9]));
+ assert.deepEqual(await firstRequest, new Uint8Array([7, 8, 9]));
+ assert.deepEqual(workerOld, workerOldSnapshot);
+ assert.deepEqual(workerInput, workerInputSnapshot);
+
+ const sharedFailureOne = webApi.diffBytes(workerOld, workerInput);
+ const sharedFailureTwo = webApi.patchBytes(workerOld, workerInput);
+ assert.equal(
+ MockWorker.instances.length,
+ 1,
+ 'Requests without a signal should share one Worker'
+ );
+ firstWorker.emitError();
+ await assert.rejects(
+ sharedFailureOne,
+ (error) => error && error.code === 'EWEBASSEMBLY'
+ );
+ await assert.rejects(
+ sharedFailureTwo,
+ (error) => error && error.code === 'EWEBASSEMBLY'
+ );
+ assert.equal(firstWorker.terminated, true);
+
+ const recoveryRequest = webApi.diffBytes(workerOld, workerInput);
+ const recoveryWorker = MockWorker.instances.at(-1);
+ assert.notEqual(recoveryWorker, firstWorker);
+ firstWorker.emitMessage({
+ id: requestFor(firstWorker, 1).id,
+ type: 'result',
+ ok: true,
+ output: new Uint8Array([99]),
+ });
+ firstWorker.emitError('late worker failure');
+ assert.equal(
+ recoveryWorker.terminated,
+ false,
+ 'Late events from an old shared Worker must not reset its replacement'
+ );
+ resultFor(recoveryWorker, new Uint8Array([10]));
+ assert.deepEqual(await recoveryRequest, new Uint8Array([10]));
+
+ const messageErrorRequest = webApi.diffBytes(workerOld, workerInput);
+ recoveryWorker.emitMessageError();
+ await assert.rejects(
+ messageErrorRequest,
+ (error) => error && error.code === 'EWEBASSEMBLY'
+ );
+ assert.equal(recoveryWorker.terminated, true);
+
+ const workerCountBeforeInvalidSignal = MockWorker.instances.length;
+ await assert.rejects(
+ webApi.diffBytes(workerOld, workerInput, { signal: {} }),
+ (error) => error && error.code === 'EINVAL'
+ );
+ assert.equal(
+ MockWorker.instances.length,
+ workerCountBeforeInvalidSignal,
+ 'Malformed signals must reject before creating a Worker'
+ );
+
+ let cancellationProgressEvents = 0;
+ const cancelledJob = webApi.startDiffBytes(workerOld, workerInput);
+ const cancelledWorker = MockWorker.instances.at(-1);
+ cancelledJob.onProgress(() => {
+ cancellationProgressEvents += 1;
+ });
+ cancelledWorker.emitMessage({
+ type: 'progress',
+ progress: { operation: 'diff', phase: 'processing', progress: 0.5 },
+ });
+ const cancellation = cancelledJob.cancel();
+ assert.equal(cancelledWorker.terminated, true);
+ cancelledWorker.emitMessage({
+ type: 'progress',
+ progress: { operation: 'diff', phase: 'writing', progress: 1 },
+ });
+ resultFor(cancelledWorker, new Uint8Array([11]));
+ await cancellation;
+ await assert.rejects(
+ cancelledJob.result,
+ (error) => error && error.code === 'EABORTED'
+ );
+ assert.equal(
+ cancellationProgressEvents,
+ 1,
+ 'Cancelled jobs must ignore late Worker progress and results'
+ );
+
+ const completedJob = webApi.startPatchBytes(workerOld, workerInput);
+ const completedWorker = MockWorker.instances.at(-1);
+ resultFor(completedWorker, new Uint8Array([12]));
+ assert.deepEqual(await completedJob.result, new Uint8Array([12]));
+ await completedJob.cancel();
+ assert.deepEqual(
+ await completedJob.result,
+ new Uint8Array([12]),
+ 'Cancelling an already-completed job must preserve its result'
+ );
+
+ const patchHeader = new Uint8Array(24);
+ patchHeader.set(new TextEncoder().encode('ENDSLEY/BSDIFF43'));
+ patchHeader[16] = 1;
+ let releaseExpectedData;
+ const delayedExpected = new Blob([new Uint8Array([13])]);
+ Object.defineProperty(delayedExpected, 'arrayBuffer', {
+ value: () =>
+ new Promise((resolve) => {
+ releaseExpectedData = () => resolve(new Uint8Array([13]).buffer);
+ }),
+ });
+ const verificationAbort = new AbortController();
+ const verification = webApi.verifyPatch(
+ workerOld,
+ patchHeader,
+ delayedExpected,
+ { signal: verificationAbort.signal }
+ );
+ const verificationWorker = await waitFor(
+ () => MockWorker.instances.at(-1) !== completedWorker && MockWorker.instances.at(-1),
+ 'verifyPatch did not start a dedicated Worker'
+ );
+ await waitFor(
+ () => releaseExpectedData,
+ 'verifyPatch did not begin reading the expected payload'
+ );
+ resultFor(verificationWorker, new Uint8Array([13]));
+ verificationAbort.abort();
+ releaseExpectedData();
+ await assert.rejects(
+ verification,
+ (error) => error && error.code === 'EABORTED',
+ 'verifyPatch must observe cancellation after concurrent input reads finish'
+ );
+} finally {
+ if (workerDescriptor) {
+ Object.defineProperty(globalThis, 'Worker', workerDescriptor);
+ } else {
+ delete globalThis.Worker;
+ }
+}
+
console.log('WebAssembly diff/patch round trip passed');
diff --git a/scripts/web-test.html b/scripts/web-test.html
index e7c0bad..4d852cf 100644
--- a/scripts/web-test.html
+++ b/scripts/web-test.html
@@ -16,6 +16,8 @@ BsDiffPatch Web Test
diffBytes,
inspectPatch,
patchBytes,
+ startDiff,
+ startPatchBytes,
verifyPatch,
} from '../web/index.mjs';
@@ -26,9 +28,15 @@ BsDiffPatch Web Test
const newData = encoder.encode(
'new browser data\n'.repeat(96) + 'worker round trip\n'.repeat(32)
);
+ const newDataSnapshot = newData.slice();
+ const oldBufferBytes = oldData.buffer.byteLength;
+ const newBufferBytes = newData.buffer.byteLength;
try {
- const patchData = await diffBytes(new Blob([oldData]), newData);
+ const progressEvents = [];
+ const patchData = await diffBytes(new Blob([oldData]), newData, {
+ onProgress: (event) => progressEvents.push(event),
+ });
const restoredData = await patchBytes(oldData.buffer, patchData);
const metadata = await inspectPatch(patchData);
const verification = await verifyPatch(oldData, patchData, newData);
@@ -40,9 +48,11 @@ BsDiffPatch Web Test
const restoredMatches =
restoredData.length === newData.length &&
restoredData.every((value, index) => value === newData[index]);
- const inputsPreserved = oldData.every(
- (value, index) => value === oldDataSnapshot[index]
- );
+ const inputsPreserved =
+ oldData.buffer.byteLength === oldBufferBytes &&
+ newData.buffer.byteLength === newBufferBytes &&
+ oldData.every((value, index) => value === oldDataSnapshot[index]) &&
+ newData.every((value, index) => value === newDataSnapshot[index]);
let pathApiErrorCode;
try {
@@ -74,6 +84,36 @@ BsDiffPatch Web Test
outputLimitErrorCode = error.code;
}
+ let zeroOutputLimitErrorCode;
+ try {
+ await diffBytes(oldData, newData, { maxOutputBytes: 0 });
+ } catch (error) {
+ zeroOutputLimitErrorCode = error.code;
+ }
+
+ let nativeOutputLimitErrorCode;
+ try {
+ await diffBytes(oldData, newData, { maxOutputBytes: 24 });
+ } catch (error) {
+ nativeOutputLimitErrorCode = error.code;
+ }
+
+ let invalidLimitErrorCode;
+ try {
+ await diffBytes(oldData, newData, { maxInputBytes: -1 });
+ } catch (error) {
+ invalidLimitErrorCode = error.code;
+ }
+
+ let overflowLimitErrorCode;
+ try {
+ await diffBytes(oldData, newData, {
+ maxOutputBytes: Number.MAX_SAFE_INTEGER + 1,
+ });
+ } catch (error) {
+ overflowLimitErrorCode = error.code;
+ }
+
const abortController = new AbortController();
abortController.abort();
let abortErrorCode;
@@ -85,21 +125,29 @@ BsDiffPatch Web Test
abortErrorCode = error.code;
}
- const activeAbortController = new AbortController();
const activeOldData = new Uint8Array(4 * 1024 * 1024);
const activeNewData = activeOldData.slice();
activeNewData[activeNewData.length - 1] = 1;
- const activeOperation = diffBytes(activeOldData, activeNewData, {
- signal: activeAbortController.signal,
- });
- setTimeout(() => activeAbortController.abort(), 20);
+ const activeJob = startDiff(activeOldData, activeNewData);
+ setTimeout(() => activeJob.cancel(), 20);
let activeAbortErrorCode;
try {
- await activeOperation;
+ await activeJob.result;
} catch (error) {
activeAbortErrorCode = error.code;
}
+ const patchJob = startPatchBytes(oldData, patchData);
+ const jobProgressEvents = [];
+ const unsubscribe = patchJob.onProgress((event) =>
+ jobProgressEvents.push(event)
+ );
+ const jobRestoredData = await patchJob.result;
+ unsubscribe();
+ const jobRestoredMatches =
+ jobRestoredData.length === newData.length &&
+ jobRestoredData.every((value, index) => value === newData[index]);
+
const afterAbortData = await patchBytes(oldData, patchData);
const sharedSurvivedAbort =
afterAbortData.length === newData.length &&
@@ -112,10 +160,22 @@ BsDiffPatch Web Test
abortErrorCode !== 'EABORTED' ||
inputLimitErrorCode !== 'ERESOURCE' ||
invalidInputErrorCode !== 'EINVAL' ||
+ invalidLimitErrorCode !== 'EINVAL' ||
+ nativeOutputLimitErrorCode !== 'ERESOURCE' ||
+ overflowLimitErrorCode !== 'EINVAL' ||
outputLimitErrorCode !== 'ERESOURCE' ||
+ zeroOutputLimitErrorCode !== 'ERESOURCE' ||
pathApiErrorCode !== 'EUNSUPPORTED' ||
metadata.format !== 'ENDSLEY/BSDIFF43' ||
metadata.declaredTargetBytes !== String(newData.byteLength) ||
+ !progressEvents.some(
+ (event) => event.phase === 'processing' && event.progress > 0
+ ) ||
+ progressEvents.at(-1)?.phase !== 'writing' ||
+ progressEvents.at(-1)?.progress !== 1 ||
+ !jobProgressEvents.every((event) => event.id === patchJob.id) ||
+ !jobProgressEvents.some((event) => event.phase === 'processing') ||
+ !jobRestoredMatches ||
!verification.verified ||
mismatch.verified ||
!sharedSurvivedAbort
@@ -129,12 +189,19 @@ BsDiffPatch Web Test
inputsPreserved,
inputLimitErrorCode,
invalidInputErrorCode,
+ invalidLimitErrorCode,
metadataFormat: metadata.format,
+ nativeOutputLimitErrorCode,
+ jobProgress: jobProgressEvents.length,
+ jobRestoredMatches,
mismatchVerified: mismatch.verified,
outputLimitErrorCode,
+ overflowLimitErrorCode,
patchLength: patchData.length,
pathApiErrorCode,
restoredMatches,
+ truthfulProgress: progressEvents.length,
+ zeroOutputLimitErrorCode,
sharedSurvivedAbort,
verificationPassed: verification.verified,
};
diff --git a/site/assets/planner.js b/site/assets/planner.js
new file mode 100644
index 0000000..8e3e416
--- /dev/null
+++ b/site/assets/planner.js
@@ -0,0 +1,354 @@
+/* eslint-env browser */
+
+import { diffBytes } from '/web/index.mjs';
+import {
+ canonicalJson,
+ createPatchBundle,
+ PATCH_FORMAT,
+} from '/toolkit/index.mjs';
+
+const localized = document.documentElement.lang === 'zh-CN';
+const elements = {
+ baselineCount: document.querySelector('#planner-baseline-count'),
+ baselines: document.querySelector('#planner-baseline-files'),
+ cancel: document.querySelector('#planner-cancel'),
+ copyReport: document.querySelector('#planner-copy-report'),
+ downloadManifest: document.querySelector('#planner-download-manifest'),
+ fallbackCount: document.querySelector('#planner-fallback-count'),
+ matrix: document.querySelector('#planner-matrix'),
+ patchCount: document.querySelector('#planner-patch-count'),
+ ratio: document.querySelector('#planner-max-ratio'),
+ releaseId: document.querySelector('#planner-release-id'),
+ reset: document.querySelector('#planner-reset'),
+ run: document.querySelector('#planner-run'),
+ runtime: document.querySelector('#planner-runtime-state'),
+ savings: document.querySelector('#planner-savings'),
+ status: document.querySelector('#planner-status'),
+ target: document.querySelector('#planner-target-file'),
+};
+const ui = localized
+ ? {
+ aborted: '发布计划已取消',
+ copied: '已复制发布报告',
+ fallback: '完整文件',
+ invalidRatio: '最大补丁比例必须在 0 到 1 之间',
+ noFiles: '请选择一个目标文件和至少一个基线文件',
+ noSelection: '尚未选择文件',
+ patch: '使用补丁',
+ planning: (current, total, name) =>
+ `正在处理 ${current}/${total}:${name}`,
+ ready: 'Web API 已就绪',
+ reportEmpty: '生成计划后将在这里显示补丁矩阵。',
+ success: (patches, fallbacks) =>
+ `计划完成:${patches} 条差量路径,${fallbacks} 条完整文件回退`,
+ }
+ : {
+ aborted: 'Release planning was cancelled',
+ copied: 'Release report copied',
+ fallback: 'Full file',
+ invalidRatio: 'Maximum patch ratio must be between 0 and 1',
+ noFiles: 'Choose one target file and at least one baseline',
+ noSelection: 'No file selected',
+ patch: 'Use patch',
+ planning: (current, total, name) =>
+ `Planning ${current}/${total}: ${name}`,
+ ready: 'Web API ready',
+ reportEmpty: 'The patch matrix will appear after planning.',
+ success: (patches, fallbacks) =>
+ `Plan complete: ${patches} delta routes, ${fallbacks} full-file fallbacks`,
+ };
+
+let controller;
+let currentBundle;
+let currentPatches = new Map();
+let currentReport = '';
+
+function setStatus(state, message) {
+ elements.status.dataset.state = state;
+ elements.status.textContent = message;
+}
+
+function formatBytes(bytes) {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KiB`;
+ }
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
+}
+
+async function sha256(data) {
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return [...new Uint8Array(digest)]
+ .map((value) => value.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function safeName(value) {
+ return value.replace(/[^a-zA-Z0-9._-]+/g, '-');
+}
+
+function download(name, data, type = 'application/octet-stream') {
+ const url = URL.createObjectURL(new Blob([data], { type }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+function updateFileSummary(input) {
+ const output = document.querySelector(`[data-file-summary="${input.id}"]`);
+ if (!output) {
+ return;
+ }
+ if (!input.files?.length) {
+ output.textContent = ui.noSelection;
+ return;
+ }
+ if (input.files.length === 1) {
+ output.textContent = `${input.files[0].name} · ${formatBytes(
+ input.files[0].size
+ )}`;
+ return;
+ }
+ const bytes = [...input.files].reduce((sum, file) => sum + file.size, 0);
+ output.textContent = `${input.files.length} files · ${formatBytes(bytes)}`;
+}
+
+for (const input of [elements.target, elements.baselines]) {
+ input.addEventListener('change', () => updateFileSummary(input));
+ const drop = input.closest('[data-file-drop]');
+ for (const eventName of ['dragenter', 'dragover']) {
+ drop.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ drop.classList.add('is-dragging');
+ });
+ }
+ for (const eventName of ['dragleave', 'drop']) {
+ drop.addEventListener(eventName, () => {
+ drop.classList.remove('is-dragging');
+ });
+ }
+}
+
+function renderMatrix(decisions) {
+ elements.matrix.replaceChildren();
+ for (const decision of decisions) {
+ const row = document.createElement('tr');
+ const shortHash = `${decision.baseline.sha256.slice(0, 12)}…`;
+ row.innerHTML = `
+
+
+
+
+
+ `;
+ row.cells[0].querySelector('strong').textContent = decision.baseline.name;
+ row.cells[0].querySelector('small').textContent = formatBytes(
+ decision.baseline.bytes
+ );
+ const digest = row.cells[1].querySelector('code');
+ digest.title = decision.baseline.sha256;
+ digest.textContent = shortHash;
+ row.cells[2].textContent = formatBytes(decision.patchBytes);
+ row.cells[3].textContent = `${(decision.ratio * 100).toFixed(1)}%`;
+ const strategy = row.cells[4].querySelector('.planner-decision');
+ strategy.dataset.strategy = decision.strategy;
+ strategy.textContent =
+ decision.strategy === 'patch' ? ui.patch : ui.fallback;
+ if (decision.strategy === 'patch') {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'table-download';
+ button.textContent = '.patch ↓';
+ button.addEventListener('click', () => {
+ download(decision.patch.name, currentPatches.get(decision.patch.name));
+ });
+ row.lastElementChild.append(button);
+ } else {
+ row.lastElementChild.textContent = decision.targetName;
+ }
+ elements.matrix.append(row);
+ }
+}
+
+function resetResults() {
+ currentBundle = undefined;
+ currentPatches = new Map();
+ currentReport = '';
+ elements.baselineCount.textContent = '0';
+ elements.patchCount.textContent = '0';
+ elements.fallbackCount.textContent = '0';
+ elements.savings.textContent = '—';
+ elements.downloadManifest.disabled = true;
+ elements.copyReport.disabled = true;
+ elements.matrix.innerHTML = `${ui.reportEmpty} `;
+}
+
+async function planRelease() {
+ const targetFile = elements.target.files?.[0];
+ const baselines = [...(elements.baselines.files || [])].sort((left, right) =>
+ left.name.localeCompare(right.name)
+ );
+ if (!targetFile || baselines.length === 0) {
+ setStatus('error', ui.noFiles);
+ return;
+ }
+ const maximumRatio = Number(elements.ratio.value);
+ if (!Number.isFinite(maximumRatio) || maximumRatio < 0 || maximumRatio > 1) {
+ setStatus('error', ui.invalidRatio);
+ return;
+ }
+
+ resetResults();
+ controller = new AbortController();
+ elements.run.disabled = true;
+ elements.cancel.disabled = false;
+ try {
+ const targetData = new Uint8Array(await targetFile.arrayBuffer());
+ const target = {
+ bytes: targetData.byteLength,
+ name: targetFile.name,
+ sha256: await sha256(targetData),
+ url: targetFile.name,
+ };
+ const decisions = [];
+ const candidates = [];
+ let selectedBytes = 0;
+
+ for (let index = 0; index < baselines.length; index += 1) {
+ const baselineFile = baselines[index];
+ setStatus(
+ 'running',
+ ui.planning(index + 1, baselines.length, baselineFile.name)
+ );
+ const baselineData = new Uint8Array(await baselineFile.arrayBuffer());
+ const baseline = {
+ bytes: baselineData.byteLength,
+ name: baselineFile.name,
+ sha256: await sha256(baselineData),
+ };
+ const patchData = await diffBytes(baselineData, targetData, {
+ signal: controller.signal,
+ });
+ const patchName = `${String(index + 1).padStart(3, '0')}-${safeName(
+ baselineFile.name
+ )}.patch`;
+ const patch = {
+ bytes: patchData.byteLength,
+ name: patchName,
+ sha256: await sha256(patchData),
+ url: patchName,
+ };
+ const ratio = patch.bytes / Math.max(1, target.bytes);
+ const strategy = ratio <= maximumRatio ? 'patch' : 'full';
+ if (strategy === 'patch') {
+ candidates.push({
+ baseline,
+ declaredTargetBytes: String(target.bytes),
+ format: PATCH_FORMAT,
+ patch,
+ });
+ currentPatches.set(patchName, patchData);
+ selectedBytes += patch.bytes;
+ } else {
+ selectedBytes += target.bytes;
+ }
+ decisions.push({
+ baseline,
+ patch,
+ patchBytes: patch.bytes,
+ ratio,
+ strategy,
+ targetName: target.name,
+ });
+ }
+
+ currentBundle = createPatchBundle({
+ full: target,
+ patches: candidates,
+ releaseId: elements.releaseId.value.trim() || undefined,
+ target,
+ });
+ const patchCount = candidates.length;
+ const fallbackCount = baselines.length - patchCount;
+ const fullTransferBytes = target.bytes * baselines.length;
+ const savings =
+ fullTransferBytes === 0
+ ? 0
+ : Math.max(0, 1 - selectedBytes / fullTransferBytes);
+ elements.baselineCount.textContent = String(baselines.length);
+ elements.patchCount.textContent = String(patchCount);
+ elements.fallbackCount.textContent = String(fallbackCount);
+ elements.savings.textContent = `${(savings * 100).toFixed(1)}%`;
+ renderMatrix(decisions);
+ currentReport = [
+ 'Verified Delta Release Plan',
+ `Target: ${target.name}`,
+ `Target SHA-256: ${target.sha256}`,
+ `Target bytes: ${target.bytes}`,
+ `Patch routes: ${patchCount}`,
+ `Full fallbacks: ${fallbackCount}`,
+ `Estimated transfer saved: ${(savings * 100).toFixed(1)}%`,
+ '',
+ ...decisions.map(
+ (decision) =>
+ `${decision.baseline.name} -> ${decision.strategy.toUpperCase()} (${(
+ decision.ratio * 100
+ ).toFixed(1)}%, ${decision.baseline.sha256})`
+ ),
+ ].join('\n');
+ elements.downloadManifest.disabled = false;
+ elements.copyReport.disabled = false;
+ setStatus('success', ui.success(patchCount, fallbackCount));
+ } catch (error) {
+ setStatus(
+ 'error',
+ error && error.code === 'EABORTED'
+ ? ui.aborted
+ : `[${error.code || 'EPLANNER'}] ${error.message || error}`
+ );
+ } finally {
+ controller = undefined;
+ elements.run.disabled = false;
+ elements.cancel.disabled = true;
+ }
+}
+
+elements.run.addEventListener('click', planRelease);
+elements.cancel.addEventListener('click', () => controller?.abort());
+elements.reset.addEventListener('click', () => {
+ controller?.abort();
+ elements.target.value = '';
+ elements.baselines.value = '';
+ elements.ratio.value = '0.85';
+ elements.releaseId.value = '';
+ updateFileSummary(elements.target);
+ updateFileSummary(elements.baselines);
+ resetResults();
+ setStatus('idle', ui.ready);
+});
+elements.downloadManifest.addEventListener('click', () => {
+ if (currentBundle) {
+ download(
+ 'bundle-manifest.json',
+ `${JSON.stringify(currentBundle, null, 2)}\n`,
+ 'application/json'
+ );
+ download(
+ 'bundle-manifest.canonical.json',
+ canonicalJson(currentBundle),
+ 'application/json'
+ );
+ }
+});
+elements.copyReport.addEventListener('click', async () => {
+ await navigator.clipboard.writeText(currentReport);
+ setStatus('success', ui.copied);
+});
+
+resetResults();
+elements.runtime.dataset.state = 'ready';
+elements.runtime.textContent = ui.ready;
diff --git a/site/assets/site.css b/site/assets/site.css
index a552b09..dc5a0ea 100644
--- a/site/assets/site.css
+++ b/site/assets/site.css
@@ -2326,6 +2326,262 @@ html[lang='zh-CN'] .lang-zh {
}
}
+.planner-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
+ gap: clamp(44px, 7vw, 104px);
+ align-items: center;
+ min-height: 610px;
+ padding: 84px clamp(28px, 6vw, 92px);
+ border-inline: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.planner-hero h1 {
+ max-width: 920px;
+ margin: 18px 0 28px;
+ font-size: clamp(52px, 7vw, 96px);
+ line-height: 0.94;
+ letter-spacing: -0.065em;
+}
+
+.planner-workspace,
+.planner-automation {
+ border-inline: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.planner-heading {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
+ gap: 60px;
+ padding: 58px clamp(28px, 6vw, 92px);
+ border-bottom: 1px solid var(--line);
+}
+
+.planner-heading h2,
+.planner-automation h2 {
+ margin: 10px 0 0;
+ font-size: clamp(30px, 4vw, 54px);
+ line-height: 1.02;
+ letter-spacing: -0.045em;
+}
+
+.planner-heading > p,
+.planner-automation > div:first-child > p:last-child {
+ max-width: 620px;
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.75;
+}
+
+.planner-form {
+ padding: 44px clamp(22px, 5vw, 72px);
+ border-bottom: 1px solid var(--line);
+}
+
+.planner-files {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.planner-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ margin-top: 18px;
+}
+
+.planner-options label {
+ display: grid;
+ gap: 9px;
+ color: var(--muted);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.planner-options input {
+ min-width: 0;
+ min-height: 50px;
+ padding: 0 15px;
+ border: 1px solid var(--line-bright);
+ border-radius: 4px;
+ outline: 0;
+ color: var(--text);
+ background: #0b1115;
+ font: inherit;
+ text-transform: none;
+}
+
+.planner-options input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(77, 225, 184, 0.12);
+}
+
+.planner-form .tool-actions {
+ margin-top: 24px;
+}
+
+.planner-form .tool-status {
+ display: block;
+ margin-top: 20px;
+ text-align: left;
+}
+
+.planner-results {
+ padding: 42px clamp(22px, 5vw, 72px) 56px;
+}
+
+.planner-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ border: 1px solid var(--line);
+}
+
+.planner-summary > div {
+ display: grid;
+ gap: 8px;
+ min-height: 110px;
+ padding: 22px;
+ border-right: 1px solid var(--line);
+}
+
+.planner-summary > div:last-child {
+ border-right: 0;
+}
+
+.planner-summary span {
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.planner-summary strong {
+ align-self: end;
+ color: var(--accent);
+ font-size: 24px;
+}
+
+.planner-table-wrap {
+ margin-top: 24px;
+ overflow-x: auto;
+ border: 1px solid var(--line);
+}
+
+.planner-table {
+ width: 100%;
+ min-width: 820px;
+ border-collapse: collapse;
+}
+
+.planner-table th,
+.planner-table td {
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+}
+
+.planner-table th {
+ color: var(--muted);
+ background: #0b1115;
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.planner-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.planner-table td:first-child {
+ display: grid;
+ gap: 5px;
+}
+
+.planner-table td small {
+ color: var(--muted);
+}
+
+.planner-table code {
+ color: #a8bdc3;
+}
+
+.planner-decision {
+ display: inline-flex;
+ padding: 5px 9px;
+ border: 1px solid rgba(77, 225, 184, 0.35);
+ border-radius: 999px;
+ color: var(--accent);
+ background: rgba(77, 225, 184, 0.08);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.planner-decision[data-strategy='full'] {
+ border-color: rgba(245, 183, 77, 0.35);
+ color: #f5b74d;
+ background: rgba(245, 183, 77, 0.08);
+}
+
+.table-download {
+ padding: 7px 10px;
+ border: 1px solid var(--line-bright);
+ border-radius: 3px;
+ color: var(--text);
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ font-size: 11px;
+}
+
+.planner-results .result-actions {
+ margin-top: 20px;
+}
+
+.planner-automation {
+ display: grid;
+ grid-template-columns: minmax(280px, 0.65fr) minmax(0, 1.35fr);
+ gap: 56px;
+ padding: 64px clamp(28px, 6vw, 92px);
+}
+
+.planner-code-grid {
+ display: grid;
+ gap: 18px;
+}
+
+.planner-code-grid article {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ background: #080d10;
+}
+
+.planner-code-grid header {
+ display: flex;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 10px;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.planner-code-grid pre {
+ margin: 0;
+ padding: 20px;
+ overflow-x: auto;
+ color: #c7d6da;
+ font-size: 12px;
+ line-height: 1.65;
+}
+
@media (max-width: 1180px) {
.hero {
grid-template-columns: 1fr;
@@ -2706,6 +2962,11 @@ html[lang='zh-CN'] .lang-zh {
grid-template-columns: 1fr;
}
+ .planner-hero,
+ .planner-automation {
+ grid-template-columns: 1fr;
+ }
+
.privacy-card {
max-width: 720px;
}
@@ -2741,6 +3002,29 @@ html[lang='zh-CN'] .lang-zh {
padding-inline: 28px;
}
+ .planner-hero,
+ .planner-heading,
+ .planner-automation {
+ padding-inline: 28px;
+ }
+
+ .planner-heading {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ }
+
+ .planner-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .planner-summary > div:nth-child(2) {
+ border-right: 0;
+ }
+
+ .planner-summary > div:nth-child(-n + 2) {
+ border-bottom: 1px solid var(--line);
+ }
+
.toolkit-heading,
.utility-suite-heading,
.tool-safety {
@@ -2798,6 +3082,46 @@ html[lang='zh-CN'] .lang-zh {
padding: 48px 18px;
}
+ .planner-hero {
+ min-height: auto;
+ padding: 48px 18px;
+ }
+
+ .planner-hero h1 {
+ font-size: clamp(36px, 10.4vw, 52px);
+ letter-spacing: -0.055em;
+ }
+
+ .planner-hero .headline-accent {
+ padding-inline: 6px;
+ }
+
+ .planner-heading,
+ .planner-automation {
+ padding: 44px 20px;
+ }
+
+ .planner-form,
+ .planner-results {
+ padding-inline: 16px;
+ }
+
+ .planner-files,
+ .planner-options,
+ .planner-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .planner-summary > div,
+ .planner-summary > div:nth-child(2) {
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .planner-summary > div:last-child {
+ border-bottom: 0;
+ }
+
.tools-hero h1 {
font-size: clamp(40px, 11.6vw, 58px);
}
diff --git a/site/assets/tools.js b/site/assets/tools.js
index 27a244e..fd477a0 100644
--- a/site/assets/tools.js
+++ b/site/assets/tools.js
@@ -5,6 +5,7 @@ import {
inspectPatch as inspectPatchMetadata,
patchBytes,
} from '../web/index.mjs';
+import { createPatchManifest } from '../toolkit/index.mjs';
const PATCH_MAGIC = 'ENDSLEY/BSDIFF43';
const INSPECTOR_MAX_BYTES = 256 * 1024 * 1024;
@@ -813,34 +814,24 @@ async function generateManifest() {
sha256(newFile),
sha256(patchData),
]);
- const savedBytes = newFile.size - patchFile.size;
- const savingRatio =
- newFile.size === 0 ? 0 : (savedBytes / newFile.size) * 100;
currentManifestJson = `${JSON.stringify(
- {
- manifestVersion: 1,
- patchFormat: PATCH_MAGIC,
+ createPatchManifest({
baseline: {
- filename: oldFile.name,
bytes: oldFile.size,
+ name: oldFile.name,
sha256: oldHash,
},
target: {
- filename: newFile.name,
bytes: newFile.size,
+ name: newFile.name,
sha256: newHash,
},
patch: {
- filename: patchFile.name,
bytes: patchFile.size,
+ name: patchFile.name,
sha256: patchHash,
- declaredTargetBytes: metadata.declaredTargetBytes,
},
- transfer: {
- savedBytes,
- savingRatioPercent: Number(savingRatio.toFixed(4)),
- },
- },
+ }),
null,
2
)}\n`;
diff --git a/site/index.html b/site/index.html
index 6f90ca7..f9cc6f3 100644
--- a/site/index.html
+++ b/site/index.html
@@ -67,6 +67,7 @@
Playground
Tools
+ Release Planner
Architecture
Evidence
Docs
@@ -533,6 +534,7 @@ From first patch to production boundaries.
Docs
Tools
+ Release Planner
中文
npm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{PAGE_TITLE}}
+
+
+ {{SKIP_LABEL}}
+
+
+
+
+
+
+
+
+ Verified Delta Pipeline
+ 可验证增量发布工具链
+
+
+ Plan one release.Serve every baseline.
+ 规划一次发布。覆盖每个基线。
+
+
+ Build a patch matrix from multiple old releases to one target. Keep
+ efficient deltas, fall back to the full file, and export a verified
+ CDN manifest.
+
+
+ 从多个旧版本到同一个目标生成补丁矩阵。保留高效差量,在补丁不划算时回退完整文件,
+ 并导出可验证的 CDN manifest。
+
+
+
+
+
+
+
+
+
Release Planner
+
+ Create a multi-baseline patch matrix
+ 生成多基线补丁矩阵
+
+
+
+ Browser memory still limits input size. Start with representative
+ release artifacts and reproduce production bundles with the CLI.
+
+
+ 浏览器内存仍会限制输入规模。可先用代表性发布产物评估,再通过 CLI
+ 复现生产 bundle。
+
+
+
+
+
+
+
+
+ Baselines
+ 基线数
+ 0
+
+
+ Delta routes
+ 差量路径
+ 0
+
+
+ Full fallbacks
+ 完整文件回退
+ 0
+
+
+ Estimated transfer saved
+ 预计节省传输
+ —
+
+
+
+
+
+
+ Baseline
+ 基线
+ SHA-256
+ Patch
+ 补丁
+ Ratio
+ 比例
+ Decision
+ 决策
+ Artifact
+ 产物
+
+
+
+
+ {{REPORT_EMPTY}}
+
+
+
+
+
+
+ Download bundle manifest
+ 下载 bundle manifest
+
+
+ Copy release report
+ 复制发布报告
+
+
+
+
+
+
+
+
+ Reproduce in CI/CD
+ 在 CI/CD 中复现
+
+
+ The browser plans. The CLI ships.
+ 浏览器负责规划,CLI 负责交付。
+
+
+ Use the exact same bundle schema in release jobs, GitHub Releases,
+ or your CDN pipeline.
+
+
+ 在发布任务、GitHub Releases 或 CDN 流程中使用完全相同的 bundle
+ schema。
+
+
+
+
+
+ npx react-native-bs-diff-patch bundle \
+ --from releases/ \
+ --to dist/app.bin \
+ --out dist/update-bundle \
+ --max-ratio 0.85
+
+
+
+ - uses: JimmyDaddy/react-native-bs-diff-patch@v0.5.0
+ id: delta
+ with:
+ old-file: releases/v1.bin
+ new-file: dist/app.bin
+ patch-file: dist/update.patch
+ manifest-file: dist/patch-manifest.json
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/sitemap.xml b/site/sitemap.xml
index 06a38ab..6e6c174 100644
--- a/site/sitemap.xml
+++ b/site/sitemap.xml
@@ -4,18 +4,24 @@
https://bs-dff-patch.corerobin.com/zh-CN/
https://bs-dff-patch.corerobin.com/tools/
https://bs-dff-patch.corerobin.com/zh-CN/tools/
+ https://bs-dff-patch.corerobin.com/planner/
+ https://bs-dff-patch.corerobin.com/zh-CN/planner/
https://bs-dff-patch.corerobin.com/docs/
https://bs-dff-patch.corerobin.com/docs/getting-started/
+ https://bs-dff-patch.corerobin.com/docs/web-sdk/
https://bs-dff-patch.corerobin.com/docs/api-reference/
https://bs-dff-patch.corerobin.com/docs/recipes/
+ https://bs-dff-patch.corerobin.com/docs/verified-delta-pipeline/
https://bs-dff-patch.corerobin.com/docs/platform-support/
https://bs-dff-patch.corerobin.com/docs/architecture/
https://bs-dff-patch.corerobin.com/docs/troubleshooting/
https://bs-dff-patch.corerobin.com/docs/development/
https://bs-dff-patch.corerobin.com/docs/zh-CN/
https://bs-dff-patch.corerobin.com/docs/zh-CN/getting-started/
+ https://bs-dff-patch.corerobin.com/docs/zh-CN/web-sdk/
https://bs-dff-patch.corerobin.com/docs/zh-CN/api-reference/
https://bs-dff-patch.corerobin.com/docs/zh-CN/recipes/
+ https://bs-dff-patch.corerobin.com/docs/zh-CN/verified-delta-pipeline/
https://bs-dff-patch.corerobin.com/docs/zh-CN/platform-support/
https://bs-dff-patch.corerobin.com/docs/zh-CN/architecture/
https://bs-dff-patch.corerobin.com/docs/zh-CN/troubleshooting/
diff --git a/site/tools/index.html b/site/tools/index.html
index d85634b..7790b3c 100644
--- a/site/tools/index.html
+++ b/site/tools/index.html
@@ -59,6 +59,7 @@
{{HOME_LABEL}}
{{PLAYGROUND_LABEL}}
{{TOOLS_LABEL}}
+ {{PLANNER_LABEL}}
{{DOCS_LABEL}}
{{HOME_LABEL}}
{{DOCS_LABEL}}
+ {{PLANNER_LABEL}}
npm
diff --git a/src/index.ts b/src/index.ts
index 003cd83..c8bff79 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,23 @@ export interface BinaryOperationOptions {
maxInputBytes?: number;
/** Reject when the generated or restored output exceeds this number of bytes. */
maxOutputBytes?: number;
+ /** Observe real C-core checkpoints on Web. */
+ onProgress?: (event: BinaryOperationProgress) => void;
+}
+
+export interface BinaryOperationProgress {
+ operation: 'diff' | 'patch';
+ phase: 'reading' | 'processing' | 'writing';
+ progress: number;
+}
+
+export interface BinaryOperationJob {
+ id: string;
+ result: Promise;
+ cancel(): Promise;
+ onProgress(
+ listener: (event: BinaryOperationProgress & { id: string }) => void
+ ): () => void;
}
export interface NativeOperationOptions {
@@ -65,6 +82,56 @@ export interface PatchVerificationResult {
patch: PatchMetadata;
}
+export type PatchErrorCategory =
+ | 'ABORTED'
+ | 'RESOURCE'
+ | 'INVALID_ARGUMENT'
+ | 'INVALID_PATCH'
+ | 'VERIFICATION'
+ | 'DESTINATION'
+ | 'UNSUPPORTED'
+ | 'RUNTIME';
+
+export interface ClassifiedPatchError {
+ category: PatchErrorCategory;
+ code: string;
+ message: string;
+ retryable: boolean;
+}
+
+const ERROR_CATEGORIES: Record = {
+ EABORTED: 'ABORTED',
+ ECANCELLED: 'ABORTED',
+ ERESOURCE: 'RESOURCE',
+ EINPUT_TOO_LARGE: 'RESOURCE',
+ EOUTPUT_TOO_LARGE: 'RESOURCE',
+ EINVAL: 'INVALID_ARGUMENT',
+ EINVALID_MANIFEST: 'INVALID_PATCH',
+ EPATCH: 'INVALID_PATCH',
+ ELEGACYFORMAT: 'INVALID_PATCH',
+ EBASELINEMISMATCH: 'VERIFICATION',
+ EPATCHMISMATCH: 'VERIFICATION',
+ ETARGETMISMATCH: 'VERIFICATION',
+ EDESTEXISTS: 'DESTINATION',
+ EUNSUPPORTED: 'UNSUPPORTED',
+};
+
+export function classifyPatchError(error: unknown): ClassifiedPatchError {
+ const code =
+ error &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ typeof error.code === 'string'
+ ? error.code
+ : 'EUNSPECIFIED';
+ return {
+ category: ERROR_CATEGORIES[code] ?? 'RUNTIME',
+ code,
+ message: error instanceof Error ? error.message : String(error || ''),
+ retryable: code === 'EABORTED' || code === 'ECANCELLED',
+ };
+}
+
type NativeProgressEvent = Omit;
const NATIVE_PROGRESS_EVENT = 'BsDiffPatchProgress';
@@ -309,3 +376,34 @@ export function patchBytes(
): Promise {
return rejectWebOnlyApi('patchBytes');
}
+
+let unsupportedBinaryJobSequence = 0;
+
+function unsupportedBinaryJob(methodName: string): BinaryOperationJob {
+ return {
+ id: `bsdiffpatch-native-unsupported-${++unsupportedBinaryJobSequence}`,
+ result: rejectWebOnlyApi(methodName),
+ async cancel() {},
+ onProgress() {
+ return () => {};
+ },
+ };
+}
+
+/** Start a controllable binary diff on Web. */
+export function startDiffBytes(
+ _oldData: BinaryInput,
+ _newData: BinaryInput,
+ _options?: BinaryOperationOptions
+): BinaryOperationJob {
+ return unsupportedBinaryJob('startDiffBytes');
+}
+
+/** Start a controllable binary patch operation on Web. */
+export function startPatchBytes(
+ _oldData: BinaryInput,
+ _patchData: BinaryInput,
+ _options?: BinaryOperationOptions
+): BinaryOperationJob {
+ return unsupportedBinaryJob('startPatchBytes');
+}
diff --git a/src/index.web.ts b/src/index.web.ts
index f48496a..b55f256 100644
--- a/src/index.web.ts
+++ b/src/index.web.ts
@@ -1,21 +1,29 @@
export type {
BinaryInput,
+ BinaryOperationJob,
+ BinaryOperationOptions,
+ BinaryOperationProgress,
+ ClassifiedPatchError,
NativeOperationJob,
NativeOperationOptions,
NativeOperationProgress,
PatchFormat,
+ PatchErrorCategory,
PatchInspectionOptions,
PatchMetadata,
PatchStructuralIssue,
PatchVerificationResult,
} from '../web/index.mjs';
export {
+ classifyPatchError,
diff,
diffBytes,
inspectPatch,
patch,
patchBytes,
startDiff,
+ startDiffBytes,
startPatch,
+ startPatchBytes,
verifyPatch,
} from '../web/index.mjs';
diff --git a/toolkit/index.d.ts b/toolkit/index.d.ts
new file mode 100644
index 0000000..0a5d78f
--- /dev/null
+++ b/toolkit/index.d.ts
@@ -0,0 +1,127 @@
+export const PATCH_FORMAT: 'ENDSLEY/BSDIFF43';
+export const PATCH_MANIFEST_VERSION: 1;
+export const PATCH_BUNDLE_FORMAT: string;
+
+export interface PatchArtifact {
+ bytes: number;
+ sha256: string;
+ name?: string;
+ url?: string;
+}
+
+export interface DetachedSignatureMetadata {
+ algorithm: string;
+ detached: true;
+ keyId: string;
+}
+
+export interface PatchManifest {
+ version: 1;
+ format: 'ENDSLEY/BSDIFF43';
+ baseline: PatchArtifact;
+ patch: PatchArtifact;
+ target: PatchArtifact;
+ releaseId?: string;
+ signature?: DetachedSignatureMetadata;
+}
+
+export interface PatchCandidate {
+ format: 'ENDSLEY/BSDIFF43';
+ baseline: PatchArtifact;
+ patch: PatchArtifact;
+ declaredTargetBytes: string;
+}
+
+export interface PatchBundle {
+ version: 1;
+ format: string;
+ target: PatchArtifact;
+ full: PatchArtifact;
+ patches: PatchCandidate[];
+ releaseId?: string;
+ signature?: DetachedSignatureMetadata;
+}
+
+export interface PatchMetadata {
+ declaredTargetBytes: string | null;
+ format: 'ENDSLEY/BSDIFF43' | 'BSDIFF40' | 'UNKNOWN';
+ headerBytes: number;
+ issue?:
+ | 'INVALID_MAGIC'
+ | 'INVALID_TARGET_SIZE'
+ | 'LEGACY_FORMAT'
+ | 'TRUNCATED_HEADER';
+ patchBytes: number;
+ payloadBytes: number;
+ valid: boolean;
+}
+
+export class PatchToolkitError extends Error {
+ constructor(code: string, message: string);
+ code: string;
+}
+
+export type PatchErrorCategory =
+ | 'ABORTED'
+ | 'RESOURCE'
+ | 'INVALID_ARGUMENT'
+ | 'INVALID_PATCH'
+ | 'VERIFICATION'
+ | 'DESTINATION'
+ | 'UNSUPPORTED'
+ | 'RUNTIME';
+
+export interface ClassifiedPatchError {
+ category: PatchErrorCategory;
+ code: string;
+ message: string;
+ retryable: boolean;
+}
+
+export function classifyPatchError(error: unknown): ClassifiedPatchError;
+
+export function canonicalJson(value: unknown): string;
+export function createPatchManifest(input: {
+ baseline: PatchArtifact;
+ patch: PatchArtifact;
+ target: PatchArtifact;
+ releaseId?: string;
+ signature?: DetachedSignatureMetadata;
+}): PatchManifest;
+export function validatePatchManifest(value: unknown): PatchManifest;
+export function signingPayload(manifest: PatchManifest): string;
+export function createPatchBundle(input: {
+ target: PatchArtifact;
+ full?: PatchArtifact;
+ patches?: PatchCandidate[];
+ releaseId?: string;
+ signature?: DetachedSignatureMetadata;
+}): PatchBundle;
+export function validatePatchBundle(value: unknown): PatchBundle;
+export function selectPatch(
+ bundle: PatchBundle,
+ options: {
+ baselineSha256: string;
+ maxPatchBytes?: number;
+ maxPatchRatio?: number;
+ }
+):
+ | {
+ artifact: PatchArtifact;
+ candidate: PatchCandidate;
+ reason: 'BASELINE_MATCH';
+ strategy: 'patch';
+ }
+ | {
+ artifact: PatchArtifact;
+ candidate?: PatchCandidate;
+ reason:
+ | 'BASELINE_NOT_FOUND'
+ | 'PATCH_BYTES_EXCEEDED'
+ | 'PATCH_RATIO_EXCEEDED';
+ strategy: 'full';
+ };
+export function inspectPatchHeader(
+ headerData: Uint8Array,
+ patchBytes?: number
+): PatchMetadata;
diff --git a/toolkit/index.mjs b/toolkit/index.mjs
new file mode 100644
index 0000000..3604a94
--- /dev/null
+++ b/toolkit/index.mjs
@@ -0,0 +1,432 @@
+export const PATCH_FORMAT = 'ENDSLEY/BSDIFF43';
+export const PATCH_MANIFEST_VERSION = 1;
+export const PATCH_BUNDLE_FORMAT =
+ 'react-native-bs-diff-patch/verified-bundle-v1';
+
+const SHA256_PATTERN = /^[a-f0-9]{64}$/;
+
+export class PatchToolkitError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'PatchToolkitError';
+ this.code = code;
+ }
+}
+
+const ERROR_CATEGORIES = new Map([
+ ['EABORTED', 'ABORTED'],
+ ['ECANCELLED', 'ABORTED'],
+ ['ERESOURCE', 'RESOURCE'],
+ ['EINPUT_TOO_LARGE', 'RESOURCE'],
+ ['EOUTPUT_TOO_LARGE', 'RESOURCE'],
+ ['EINVAL', 'INVALID_ARGUMENT'],
+ ['EINVALID_MANIFEST', 'INVALID_PATCH'],
+ ['EPATCH', 'INVALID_PATCH'],
+ ['ELEGACYFORMAT', 'INVALID_PATCH'],
+ ['EBASELINEMISMATCH', 'VERIFICATION'],
+ ['EPATCHMISMATCH', 'VERIFICATION'],
+ ['ETARGETMISMATCH', 'VERIFICATION'],
+ ['EDESTEXISTS', 'DESTINATION'],
+ ['EUNSUPPORTED', 'UNSUPPORTED'],
+]);
+
+export function classifyPatchError(error) {
+ const code =
+ error && typeof error === 'object' && typeof error.code === 'string'
+ ? error.code
+ : 'EUNSPECIFIED';
+ return {
+ category: ERROR_CATEGORIES.get(code) || 'RUNTIME',
+ code,
+ message:
+ error instanceof Error
+ ? error.message
+ : String(error || 'unknown patch error'),
+ retryable: code === 'EABORTED' || code === 'ECANCELLED',
+ };
+}
+
+function fail(code, message) {
+ throw new PatchToolkitError(code, message);
+}
+
+function isRecord(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function normalizeArtifact(value, fieldName) {
+ if (!isRecord(value)) {
+ fail('EINVALID_MANIFEST', `${fieldName} must be an object`);
+ }
+
+ if (!Number.isSafeInteger(value.bytes) || value.bytes < 0) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `${fieldName}.bytes must be a non-negative safe integer`
+ );
+ }
+
+ if (
+ typeof value.sha256 !== 'string' ||
+ !SHA256_PATTERN.test(value.sha256.toLowerCase())
+ ) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `${fieldName}.sha256 must be a 64-character SHA-256 hex digest`
+ );
+ }
+
+ const artifact = {
+ bytes: value.bytes,
+ sha256: value.sha256.toLowerCase(),
+ };
+ if (value.url !== undefined) {
+ if (typeof value.url !== 'string' || value.url.length === 0) {
+ fail('EINVALID_MANIFEST', `${fieldName}.url must be a non-empty string`);
+ }
+ artifact.url = value.url;
+ }
+ if (value.name !== undefined) {
+ if (typeof value.name !== 'string' || value.name.length === 0) {
+ fail('EINVALID_MANIFEST', `${fieldName}.name must be a non-empty string`);
+ }
+ artifact.name = value.name;
+ }
+ return artifact;
+}
+
+function normalizeSignature(value) {
+ if (!isRecord(value)) {
+ fail('EINVALID_MANIFEST', 'signature must be an object');
+ }
+ if (typeof value.algorithm !== 'string' || value.algorithm.length === 0) {
+ fail('EINVALID_MANIFEST', 'signature.algorithm must be a non-empty string');
+ }
+ if (typeof value.keyId !== 'string' || value.keyId.length === 0) {
+ fail('EINVALID_MANIFEST', 'signature.keyId must be a non-empty string');
+ }
+ if (value.detached !== true) {
+ fail('EINVALID_MANIFEST', 'signature.detached must be true');
+ }
+ return {
+ algorithm: value.algorithm,
+ detached: true,
+ keyId: value.keyId,
+ };
+}
+
+function normalizeDeclaredTargetBytes(value, fieldName) {
+ if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `${fieldName} must be an unsigned decimal string`
+ );
+ }
+ return value;
+}
+
+export function canonicalJson(value) {
+ const seen = new Set();
+
+ function normalize(entry) {
+ if (
+ entry === null ||
+ typeof entry === 'string' ||
+ typeof entry === 'boolean'
+ ) {
+ return entry;
+ }
+ if (typeof entry === 'number') {
+ if (!Number.isFinite(entry)) {
+ fail('EINVALID_MANIFEST', 'canonical JSON rejects non-finite numbers');
+ }
+ return entry;
+ }
+ if (!Array.isArray(entry) && !isRecord(entry)) {
+ fail('EINVALID_MANIFEST', `canonical JSON cannot encode ${typeof entry}`);
+ }
+ if (seen.has(entry)) {
+ fail('EINVALID_MANIFEST', 'canonical JSON cannot encode cycles');
+ }
+ seen.add(entry);
+ if (Array.isArray(entry)) {
+ const normalized = entry.map(normalize);
+ seen.delete(entry);
+ return normalized;
+ }
+ const normalized = Object.create(null);
+ for (const key of Object.keys(entry).sort()) {
+ if (entry[key] !== undefined) {
+ normalized[key] = normalize(entry[key]);
+ }
+ }
+ seen.delete(entry);
+ return normalized;
+ }
+
+ return JSON.stringify(normalize(value));
+}
+
+export function createPatchManifest(input) {
+ return validatePatchManifest({
+ version: PATCH_MANIFEST_VERSION,
+ format: PATCH_FORMAT,
+ baseline: input.baseline,
+ patch: input.patch,
+ target: input.target,
+ ...(input.releaseId === undefined ? {} : { releaseId: input.releaseId }),
+ ...(input.signature === undefined ? {} : { signature: input.signature }),
+ });
+}
+
+export function validatePatchManifest(value) {
+ if (!isRecord(value)) {
+ fail('EINVALID_MANIFEST', 'manifest must be an object');
+ }
+ if (value.version !== PATCH_MANIFEST_VERSION) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `manifest.version must be ${PATCH_MANIFEST_VERSION}`
+ );
+ }
+ if (value.format !== PATCH_FORMAT) {
+ fail('EINVALID_MANIFEST', `manifest.format must be ${PATCH_FORMAT}`);
+ }
+
+ const manifest = {
+ version: PATCH_MANIFEST_VERSION,
+ format: PATCH_FORMAT,
+ baseline: normalizeArtifact(value.baseline, 'baseline'),
+ patch: normalizeArtifact(value.patch, 'patch'),
+ target: normalizeArtifact(value.target, 'target'),
+ };
+ if (value.releaseId !== undefined) {
+ if (typeof value.releaseId !== 'string' || value.releaseId.length === 0) {
+ fail('EINVALID_MANIFEST', 'releaseId must be a non-empty string');
+ }
+ manifest.releaseId = value.releaseId;
+ }
+ if (value.signature !== undefined) {
+ manifest.signature = normalizeSignature(value.signature);
+ }
+ return manifest;
+}
+
+export function signingPayload(manifest) {
+ const validated = validatePatchManifest(manifest);
+ const { signature: _signature, ...unsigned } = validated;
+ return canonicalJson(unsigned);
+}
+
+function normalizePatchCandidate(value, index) {
+ if (!isRecord(value)) {
+ fail('EINVALID_MANIFEST', `patches[${index}] must be an object`);
+ }
+ if (value.format !== PATCH_FORMAT) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `patches[${index}].format must be ${PATCH_FORMAT}`
+ );
+ }
+ return {
+ format: PATCH_FORMAT,
+ baseline: normalizeArtifact(value.baseline, `patches[${index}].baseline`),
+ patch: normalizeArtifact(value.patch, `patches[${index}].patch`),
+ declaredTargetBytes: normalizeDeclaredTargetBytes(
+ value.declaredTargetBytes,
+ `patches[${index}].declaredTargetBytes`
+ ),
+ };
+}
+
+export function createPatchBundle(input) {
+ return validatePatchBundle({
+ version: PATCH_MANIFEST_VERSION,
+ format: PATCH_BUNDLE_FORMAT,
+ target: input.target,
+ full: input.full ?? input.target,
+ patches: input.patches ?? [],
+ ...(input.releaseId === undefined ? {} : { releaseId: input.releaseId }),
+ ...(input.signature === undefined ? {} : { signature: input.signature }),
+ });
+}
+
+export function validatePatchBundle(value) {
+ if (!isRecord(value)) {
+ fail('EINVALID_MANIFEST', 'bundle must be an object');
+ }
+ if (value.version !== PATCH_MANIFEST_VERSION) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `bundle.version must be ${PATCH_MANIFEST_VERSION}`
+ );
+ }
+ if (value.format !== PATCH_BUNDLE_FORMAT) {
+ fail('EINVALID_MANIFEST', `bundle.format must be ${PATCH_BUNDLE_FORMAT}`);
+ }
+ if (!Array.isArray(value.patches)) {
+ fail('EINVALID_MANIFEST', 'bundle.patches must be an array');
+ }
+
+ const bundle = {
+ version: PATCH_MANIFEST_VERSION,
+ format: PATCH_BUNDLE_FORMAT,
+ target: normalizeArtifact(value.target, 'target'),
+ full: normalizeArtifact(value.full, 'full'),
+ patches: value.patches.map(normalizePatchCandidate),
+ };
+ if (bundle.full.sha256 !== bundle.target.sha256) {
+ fail('EINVALID_MANIFEST', 'full.sha256 must match target.sha256');
+ }
+ if (bundle.full.bytes !== bundle.target.bytes) {
+ fail('EINVALID_MANIFEST', 'full.bytes must match target.bytes');
+ }
+ for (let index = 0; index < bundle.patches.length; index += 1) {
+ const candidate = bundle.patches[index];
+ if (candidate.declaredTargetBytes !== String(bundle.target.bytes)) {
+ fail(
+ 'EINVALID_MANIFEST',
+ `patches[${index}].declaredTargetBytes must match target.bytes`
+ );
+ }
+ }
+ if (value.releaseId !== undefined) {
+ if (typeof value.releaseId !== 'string' || value.releaseId.length === 0) {
+ fail('EINVALID_MANIFEST', 'releaseId must be a non-empty string');
+ }
+ bundle.releaseId = value.releaseId;
+ }
+ if (value.signature !== undefined) {
+ bundle.signature = normalizeSignature(value.signature);
+ }
+ return bundle;
+}
+
+export function selectPatch(bundleValue, options = {}) {
+ const bundle = validatePatchBundle(bundleValue);
+ const baselineSha256 =
+ typeof options.baselineSha256 === 'string'
+ ? options.baselineSha256.toLowerCase()
+ : '';
+ if (!SHA256_PATTERN.test(baselineSha256)) {
+ fail('EINVAL', 'baselineSha256 must be a 64-character SHA-256 hex digest');
+ }
+
+ if (
+ options.maxPatchBytes !== undefined &&
+ (!Number.isSafeInteger(options.maxPatchBytes) || options.maxPatchBytes < 0)
+ ) {
+ fail('EINVAL', 'maxPatchBytes must be a non-negative safe integer');
+ }
+ if (
+ options.maxPatchRatio !== undefined &&
+ (!Number.isFinite(options.maxPatchRatio) ||
+ options.maxPatchRatio < 0 ||
+ options.maxPatchRatio > 1)
+ ) {
+ fail('EINVAL', 'maxPatchRatio must be between 0 and 1');
+ }
+
+ const candidate = bundle.patches.find(
+ (entry) => entry.baseline.sha256 === baselineSha256
+ );
+ if (!candidate) {
+ return {
+ artifact: bundle.full,
+ reason: 'BASELINE_NOT_FOUND',
+ strategy: 'full',
+ };
+ }
+
+ if (
+ options.maxPatchBytes !== undefined &&
+ candidate.patch.bytes > options.maxPatchBytes
+ ) {
+ return {
+ artifact: bundle.full,
+ candidate,
+ reason: 'PATCH_BYTES_EXCEEDED',
+ strategy: 'full',
+ };
+ }
+ if (
+ options.maxPatchRatio !== undefined &&
+ candidate.patch.bytes / Math.max(1, bundle.full.bytes) >
+ options.maxPatchRatio
+ ) {
+ return {
+ artifact: bundle.full,
+ candidate,
+ reason: 'PATCH_RATIO_EXCEEDED',
+ strategy: 'full',
+ };
+ }
+
+ return {
+ artifact: candidate.patch,
+ candidate,
+ reason: 'BASELINE_MATCH',
+ strategy: 'patch',
+ };
+}
+
+export function inspectPatchHeader(headerData, patchBytes) {
+ if (!(headerData instanceof Uint8Array)) {
+ fail('EINVAL', 'headerData must be a Uint8Array');
+ }
+ if (patchBytes === undefined) {
+ patchBytes = headerData.byteLength;
+ }
+ if (!Number.isSafeInteger(patchBytes) || patchBytes < headerData.byteLength) {
+ fail('EINVAL', 'patchBytes must include every provided header byte');
+ }
+
+ const headerBytes = Math.min(headerData.byteLength, 24);
+ const magic = String.fromCharCode(
+ ...headerData.subarray(0, Math.min(headerBytes, 16))
+ );
+ const legacyMagic = magic.slice(0, 8);
+ const common = {
+ headerBytes,
+ patchBytes,
+ payloadBytes: Math.max(0, patchBytes - 24),
+ };
+ if (headerBytes < 24) {
+ return {
+ ...common,
+ declaredTargetBytes: null,
+ format: legacyMagic === 'BSDIFF40' ? 'BSDIFF40' : 'UNKNOWN',
+ issue: legacyMagic === 'BSDIFF40' ? 'LEGACY_FORMAT' : 'TRUNCATED_HEADER',
+ valid: false,
+ };
+ }
+ if (magic !== PATCH_FORMAT) {
+ return {
+ ...common,
+ declaredTargetBytes: null,
+ format: legacyMagic === 'BSDIFF40' ? 'BSDIFF40' : 'UNKNOWN',
+ issue: legacyMagic === 'BSDIFF40' ? 'LEGACY_FORMAT' : 'INVALID_MAGIC',
+ valid: false,
+ };
+ }
+ if ((headerData[23] & 0x80) !== 0) {
+ return {
+ ...common,
+ declaredTargetBytes: null,
+ format: PATCH_FORMAT,
+ issue: 'INVALID_TARGET_SIZE',
+ valid: false,
+ };
+ }
+
+ let targetBytes = 0n;
+ for (let index = 23; index >= 16; index -= 1) {
+ targetBytes = targetBytes * 256n + BigInt(headerData[index]);
+ }
+ return {
+ ...common,
+ declaredTargetBytes: targetBytes.toString(),
+ format: PATCH_FORMAT,
+ valid: true,
+ };
+}
diff --git a/web/bsdiffpatch.browser.mjs b/web/bsdiffpatch.browser.mjs
new file mode 100644
index 0000000..129ba6d
--- /dev/null
+++ b/web/bsdiffpatch.browser.mjs
@@ -0,0 +1,2 @@
+async function Module(moduleArg={}){var Module=moduleArg;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var out=(...args)=>console.log(...args);var err=(...args)=>console.error(...args);function ready(){}function assert(condition,message){if(!condition){throw new Error(message||"WebAssembly runtime assertion failed")}}function abort(what){throw what}class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}function binaryDecode(bin){for(var i=0,l=bin.length,o=new Uint8Array(l),c;i>8&c}return o}var runtimeInitialized=false;function getMemoryBuffer(){return wasmMemory.buffer}function updateMemoryViews(){if(HEAP8?.buffer?.resizable)return;var b=getMemoryBuffer();Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.slice(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.slice(0,-1)}return root+dir},basename:path=>path&&path.match(/([^\/]+|\/)\/*$/)[1],join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>view=>(crypto.getRandomValues(view),0);var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).slice(1);to=PATH_FS.resolve(to).slice(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var FS_stdin_getChar_buffer=[];var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var intArrayFromString=(stringy,dontAddNull,length)=>{var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array};var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(globalThis.window?.prompt){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output?.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var mmapAlloc=size=>{abort()};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16895,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=MEMFS.emptyFileContents??=new Uint8Array(0)}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.atime=node.mtime=node.ctime=Date.now();if(parent){parent.contents[name]=node;parent.atime=parent.mtime=parent.ctime=node.atime}return node},getFileDataAsTypedArray(node){return node.contents.subarray(0,node.usedBytes)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents.length;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity)newCapacity=Math.max(newCapacity,256);var oldContents=MEMFS.getFileDataAsTypedArray(node);node.contents=new Uint8Array(newCapacity);node.contents.set(oldContents)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;var oldContents=node.contents;node.contents=new Uint8Array(newSize);node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)));node.usedBytes=newSize},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.atime);attr.mtime=new Date(node.mtime);attr.ctime=new Date(node.ctime);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){for(const key of["mode","atime","mtime","ctime"]){if(attr[key]!=null){node[key]=attr[key]}}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){if(!MEMFS.doesNotExistError){MEMFS.doesNotExistError=new FS.ErrnoError(44);MEMFS.doesNotExistError.stack=""}throw MEMFS.doesNotExistError},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){if(FS.isDir(old_node.mode)){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}FS.hashRemoveNode(new_node)}delete old_node.parent.contents[old_node.name];new_dir.contents[new_name]=old_node;old_node.name=new_name;new_dir.ctime=new_dir.mtime=old_node.parent.ctime=old_node.parent.mtime=Date.now()},unlink(parent,name){delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},readdir(node){return[".","..",...Object.keys(node.contents)]},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);buffer.set(contents.subarray(position,position+size),offset);return size},write(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.mtime=node.ctime=Date.now();if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length}else{MEMFS.expandFileStorage(node,position+length);node.contents.set(buffer.subarray(offset,offset+length),position);node.usedBytes=Math.max(node.usedBytes,position+length)}return length},llseek(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},mmap(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}if(contents){if(position>0||position+length{if(typeof str!="string")return str;var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_fileDataToTypedArray=data=>{if(typeof data=="string"){data=intArrayFromString(data,true)}if(!data.subarray){data=new Uint8Array(data)}return data};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount(mount){assert(ENVIRONMENT_IS_WORKER);WORKERFS.reader??=new FileReaderSync;var root=WORKERFS.createNode(null,"/",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split("/");var parent=root;for(var i=0;i=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,filesystems:null,syncFSRequests:0,ErrnoError:class{name="ErrnoError";constructor(errno){this.errno=errno}},FSStream:class{shared={};get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{node_ops={};stream_ops={};readMode=292|73;writeMode=146;mounted=null;constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.rdev=rdev;this.atime=this.mtime=this.ctime=Date.now()}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}addListener(cb,exclusive=false){var entry={cb,exclusive};var listeners=this.listeners??=new Set;listeners.add(entry);return{listeners,entry}}notifyListeners(flags){if(!this.listeners)return;var excl;for(var entry of this.listeners){if(entry.exclusive)(excl||=[]).push(entry);else entry.cb(flags)}if(excl){var i=(this.exclTurn||0)%excl.length;this.exclTurn=i+1;excl[i].cb(flags)}}},lookupPath(path,opts={}){if(!path){throw new FS.ErrnoError(44)}opts.follow_mount??=true;if(!PATH.isAbs(path)){path=FS.cwd()+"/"+path}linkloop:for(var nlinks=0;nlinks<40;nlinks++){var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}if(perms.includes("w")&&!(node.mode&146)){return 2}if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){if(!FS.isDir(dir.mode)){return 54}try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else if(FS.isDir(node.mode)){return 31}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}var mode=FS.flagsToPermissionString(flags);if(FS.isDir(node.mode)){if(mode!=="r"||flags&(512|64)){return 31}}return FS.nodePermissions(node,mode)},checkOpExists(op,err){if(!op){throw new FS.ErrnoError(err)}return op},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},doSetAttr(stream,node,attr){var setattr=stream?.stream_ops.setattr;var arg=setattr?stream:node;setattr??=node.node_ops.setattr;FS.checkOpExists(setattr,63);try{setattr(arg,attr)}catch(e){if(e instanceof RangeError){throw new FS.ErrnoError(22)}throw e}},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}for(var mount of mounts){if(mount.type.syncfs){mount.type.syncfs(mount,populate,done)}else{done(null)}}},mount(type,opts,mountpoint){var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);for(var[hash,current]of Object.entries(FS.nameTable)){while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}}node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name){throw new FS.ErrnoError(28)}if(name==="."||name===".."){throw new FS.ErrnoError(20)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},statfs(path){return FS.statfsNode(FS.lookupPath(path,{follow:true}).node)},statfsStream(stream){return FS.statfsNode(stream.node)},statfsNode(node){var rtn={bsize:4096,frsize:4096,blocks:1e6,bfree:5e5,bavail:5e5,files:FS.nextInode,ffree:FS.nextInode-1,fsid:42,flags:2,namelen:255};if(node.node_ops.statfs){Object.assign(rtn,node.node_ops.statfs(node.mount.opts.root))}return rtn},create(path,mode=438){mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode=511){mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var dir of dirs){if(!dir)continue;if(d||PATH.isAbs(path))d+="/";d+=dir;try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev(path,mode,dev){if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink(oldpath,newpath){if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},link(oldpath,newpath,flags){var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.link){throw new FS.ErrnoError(34)}return parent.node_ops.link(parent,newname,oldpath,flags)},rename(old_path,new_path){var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name);old_node.parent=new_dir}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir(path){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var readdir=FS.checkOpExists(node.node_ops.readdir,54);return readdir(node)},unlink(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink(path){var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return link.node_ops.readlink(link)},stat(path,dontFollow){var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;var getattr=FS.checkOpExists(node.node_ops.getattr,63);return getattr(node)},fstat(fd){var stream=FS.getStreamChecked(fd);var node=stream.node;var getattr=stream.stream_ops.getattr;var arg=getattr?stream:node;getattr??=node.node_ops.getattr;FS.checkOpExists(getattr,63);return getattr(arg)},lstat(path){return FS.stat(path,true)},doChmod(stream,node,mode,dontFollow){FS.doSetAttr(stream,node,{mode:mode&4095|node.mode&~4095,ctime:Date.now(),dontFollow})},chmod(path,mode,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChmod(null,node,mode,dontFollow)},lchmod(path,mode){FS.chmod(path,mode,true)},fchmod(fd,mode){var stream=FS.getStreamChecked(fd);FS.doChmod(stream,stream.node,mode,false)},doChown(stream,node,dontFollow){FS.doSetAttr(stream,node,{timestamp:Date.now(),dontFollow})},chown(path,uid,gid,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChown(null,node,dontFollow)},lchown(path,uid,gid){FS.chown(path,uid,gid,true)},fchown(fd,uid,gid){var stream=FS.getStreamChecked(fd);FS.doChown(stream,stream.node,false)},doTruncate(stream,node,len){if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}FS.doSetAttr(stream,node,{size:len,timestamp:Date.now()})},truncate(path,len){if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}FS.doTruncate(null,node,len)},ftruncate(fd,len){var stream=FS.getStreamChecked(fd);if(len<0||(stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.doTruncate(stream,stream.node,len)},utime(path,atime,mtime,dontFollow){var lookup=FS.lookupPath(path,{follow:!dontFollow});FS.doSetAttr(null,lookup.node,{atime,mtime,dontFollow})},open(path,flags,mode=438){if(path===""){throw new FS.ErrnoError(44)}flags=FS_modeStringToFlags(flags);if(flags&64){mode=mode&4095|32768}else{mode=0}var node;var isDirPath;if(typeof path=="object"){node=path}else{isDirPath=path.endsWith("/");var lookup=FS.lookupPath(path,{follow:!(flags&131072),noent_okay:true});node=lookup.node;path=lookup.path}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else if(isDirPath){throw new FS.ErrnoError(31)}else{node=FS.mknod(path,mode|511,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node,path:FS.getPath(node),flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(created){FS.chmod(node,mode&511)}return stream},close(stream){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;stream.node?.notifyListeners(32);try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed(stream){return stream.fd===null},llseek(stream,offset,whence){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read(stream,buffer,offset,length,position){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags??0;opts.encoding=opts.encoding??"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){abort(`Invalid encoding type "${opts.encoding}"`)}var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){buf=UTF8ArrayToString(buf)}FS.close(stream);return buf},writeFile(path,data,opts={}){opts.flags=opts.flags??577;var stream=FS.open(path,opts.flags,opts.mode);data=FS_fileDataToTypedArray(data);FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn);FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length,llseek:()=>0});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomFill(randomBuffer);randomLeft=randomBuffer.byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16895,73);node.stream_ops={llseek:MEMFS.stream_ops.llseek};node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path},id:fd+1};ret.parent=ret;return ret},readdir(){return Array.from(FS.streams.entries()).filter(([k,v])=>v).map(([k,v])=>k.toString())}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},staticInit(){FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS,WORKERFS}},init(input,output,error){FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;for(var stream of FS.streams){if(stream){FS.close(stream)}}},findObject(path,dontResolveLastLink){var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath(path,dontResolveLastLink){try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath(parent,path,canRead,canWrite){parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){if(e.errno!=20)throw e}parent=current}return current},createFile(parent,name,properties,canRead,canWrite){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile(parent,name,data,canRead,canWrite,canOwn){var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){data=FS_fileDataToTypedArray(data);FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}},createDevice(parent,name,input,output){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);FS.createDevice.major??=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open(stream){stream.seekable=false},close(stream){if(output?.buffer?.length){output(10)}},read(stream,buffer,offset,length,pos){var bytesRead=0;for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)abort(`invalid range (${from}, ${to}) or no bytes requested!`);if(to>datalength-1)abort(`only ${datalength} bytes available! programmer error!`);var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText??"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")abort("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(globalThis.XMLHttpRequest){if(!ENVIRONMENT_IS_WORKER)abort("Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc");var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};for(const[key,fn]of Object.entries(node.stream_ops)){stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}}function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var SYSCALLS={currentUmask:18,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return dir+"/"+path},writeStat(buf,stat){HEAPU32[buf>>2]=stat.dev;HEAPU32[buf+4>>2]=stat.mode;HEAPU32[buf+8>>2]=stat.nlink;HEAPU32[buf+12>>2]=stat.uid;HEAPU32[buf+16>>2]=stat.gid;HEAPU32[buf+20>>2]=stat.rdev;HEAP64[buf+24>>3]=BigInt(stat.size);HEAP32[buf+32>>2]=4096;HEAP32[buf+36>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+40>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;HEAP64[buf+56>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;HEAP64[buf+88>>3]=BigInt(stat.ino);return 0},writeStatFs(buf,stats){HEAPU32[buf+4>>2]=stats.bsize;HEAPU32[buf+60>>2]=stats.bsize;HEAP64[buf+8>>3]=BigInt(stats.blocks);HEAP64[buf+16>>3]=BigInt(stats.bfree);HEAP64[buf+24>>3]=BigInt(stats.bavail);HEAP64[buf+32>>3]=BigInt(stats.files);HEAP64[buf+40>>3]=BigInt(stats.ffree);HEAPU32[buf+48>>2]=stats.fsid;HEAPU32[buf+64>>2]=stats.flags;HEAPU32[buf+56>>2]=stats.namelen},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.subarray(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var syscallGetVarargI=()=>{var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret};var syscallGetVarargP=syscallGetVarargI;function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();var mask=289792;stream.flags=stream.flags&~mask|arg&mask;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{return SYSCALLS.writeStat(buf,FS.fstat(fd))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;HEAP32[argp+4>>2]=termios.c_oflag||0;HEAP32[argp+8>>2]=termios.c_cflag||0;HEAP32[argp+12>>2]=termios.c_lflag||0;for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21537:case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];HEAP16[argp+2>>1]=winsize[1]}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_linkat(olddirfd,oldpath,newdirfd,newpath,flags){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.link(oldpath,newpath,flags);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;if(flags&64){mode&=~SYSCALLS.currentUmask}return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(!flags){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{return-28}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>abort("");var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false};var timers={};var clearTimers=()=>{for(var t of Object.values(timers)){clearTimeout(t.id)}};var callUserCallback=func=>{func()};var _emscripten_get_now=()=>performance.now();var __setitimer_js=(which,timeout_ms)=>{if(timers[which]){clearTimeout(timers[which].id);delete timers[which]}if(!timeout_ms)return 0;var id=setTimeout(()=>{delete timers[which];callUserCallback(()=>__emscripten_timeout(which,_emscripten_get_now()))},timeout_ms);timers[which]={id,timeout_ms};return 0};var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _proc_exit=code=>{throw`exit(${code})`};var _exit=_proc_exit;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;try{var curr=FS.read(stream,HEAP8,ptr,len,offset)}catch(e){if(ret>0&&e instanceof FS.ErrnoError&&(e.errno==6||e.errno==6)){break}throw e}if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);try{if(isNaN(offset))return 22;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_sync(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);var rtn=stream.stream_ops?.fsync?.(stream);return rtn}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{if(iovcnt==1){return FS.write(stream,HEAP8,HEAPU32[iov>>2],HEAPU32[iov+4>>2],offset)}var total=0;for(var i=0,p=iov;i>2]}var view=new Uint8Array(total);var voff=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];view.set(HEAPU8.subarray(ptr,ptr+len),voff);voff+=len}return FS.write(stream,view,0,total,offset)};function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i