From 452c239fee95601ae72d07882a4d5b14275d8667 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 09:42:20 +0800 Subject: [PATCH 1/4] feat: harden cross-platform package and runtime --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug.yml | 49 ++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature.yml | 23 ++ .github/PULL_REQUEST_TEMPLATE.md | 23 ++ .github/dependabot.yml | 34 +++ .github/workflows/ci.yml | 75 +++++- .github/workflows/npm-publish.yml | 2 + CHANGELOG.md | 15 ++ README.md | 46 +++- README.zh-CN.md | 45 +++- SECURITY.md | 29 ++ .../bsdiffpatch/BsDiffPatchNative.kt | 31 ++- benchmarks/README.md | 15 ++ benchmarks/web-wasm.json | 32 +++ compatibility/android-api/build.gradle | 61 +++++ compatibility/android-api/gradle.properties | 3 + compatibility/android-api/settings.gradle | 21 ++ .../android-api/src/main/AndroidManifest.xml | 1 + .../bsdiffpatch/NativeBsDiffPatchSpec.kt | 23 ++ cpp/bsdiff.c | 120 ++++++--- cpp/bsdiff.h | 1 - cpp/bspatch.c | 140 ++++++---- cpp/bspatch.h | 1 - cpp/fuzz/bspatch_fuzzer.c | 95 +++++++ docs/api-reference.md | 43 ++- docs/architecture.md | 34 ++- docs/development.md | 36 ++- docs/getting-started.md | 15 +- docs/platform-support.md | 14 +- docs/recipes.md | 29 +- docs/troubleshooting.md | 22 +- docs/zh-CN/api-reference.md | 37 ++- docs/zh-CN/architecture.md | 29 +- docs/zh-CN/development.md | 31 ++- docs/zh-CN/getting-started.md | 14 +- docs/zh-CN/platform-support.md | 10 +- docs/zh-CN/recipes.md | 28 +- docs/zh-CN/troubleshooting.md | 18 +- example/src/App.tsx | 95 ++++++- fixtures/cross-platform.json | 6 + ios/BsDiffPatch.mm | 18 +- package.json | 31 ++- scripts/benchmark-web.mjs | 80 ++++++ scripts/prepare-package.mjs | 27 ++ scripts/test-native-fuzz.sh | 44 +++ scripts/test-package-consumers.mjs | 224 ++++++++++++++++ scripts/test-rn-android-compatibility.sh | 28 ++ scripts/test-site-browser.mjs | 2 + scripts/test-site.mjs | 5 + scripts/test-web-browser.mjs | 5 + scripts/test-web.mjs | 42 +++ scripts/web-test.html | 59 +++- site/assets/site.css | 162 +++++++++++ site/index.html | 95 ++++++- src/index.ts | 15 +- web/bsdiffpatch.mjs | Bin 162119 -> 157668 bytes web/index.d.mts | 12 +- web/index.mjs | 252 +++++++++++++++--- web/operations.mjs | 100 +++++-- web/worker.mjs | 42 +-- 61 files changed, 2345 insertions(+), 253 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 benchmarks/README.md create mode 100644 benchmarks/web-wasm.json create mode 100644 compatibility/android-api/build.gradle create mode 100644 compatibility/android-api/gradle.properties create mode 100644 compatibility/android-api/settings.gradle create mode 100644 compatibility/android-api/src/main/AndroidManifest.xml create mode 100644 compatibility/android-api/src/newarchStubs/kotlin/com/jimmydaddy/bsdiffpatch/NativeBsDiffPatchSpec.kt create mode 100644 cpp/fuzz/bspatch_fuzzer.c create mode 100644 fixtures/cross-platform.json create mode 100644 scripts/benchmark-web.mjs create mode 100644 scripts/prepare-package.mjs create mode 100755 scripts/test-native-fuzz.sh create mode 100644 scripts/test-package-consumers.mjs create mode 100755 scripts/test-rn-android-compatibility.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e953d2a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @JimmyDaddy diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..601df58 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Report a reproducible runtime, build, packaging, or documentation defect. +title: '[Bug]: ' +labels: + - bug +body: + - type: checkboxes + attributes: + label: Before submitting + options: + - label: I searched existing issues and read the troubleshooting guide. + required: true + - label: This report does not contain sensitive files or a security vulnerability. + required: true + - type: input + attributes: + label: Package version + placeholder: 0.2.0 + validations: + required: true + - type: dropdown + attributes: + label: Runtime + options: + - Android legacy architecture + - Android New Architecture + - iOS legacy architecture + - iOS New Architecture + - React Native Web + validations: + required: true + - type: input + attributes: + label: React Native and bundler versions + description: Include React Native, browser, Metro, Webpack, or Vite versions that apply. + validations: + required: true + - type: textarea + attributes: + label: Reproduction + description: Provide minimal code, exact commands, and non-sensitive input sizes. + validations: + required: true + - type: textarea + attributes: + label: Expected and actual behavior + description: Include the complete error code and message when available. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f597d6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/JimmyDaddy/react-native-bs-diff-patch/security/advisories/new + about: Report security issues privately through GitHub. + - name: Documentation + url: https://bs-dff-patch.corerobin.com/docs/ + about: Check setup, API, platform, and troubleshooting guidance first. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..d2866d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Propose a focused addition to the public API, runtime support, or tooling. +title: '[Feature]: ' +labels: + - enhancement +body: + - type: textarea + attributes: + label: Problem + description: Describe the user problem and the affected runtime. + validations: + required: true + - type: textarea + attributes: + label: Proposed behavior + description: Explain the desired API or workflow, including compatibility expectations. + validations: + required: true + - type: textarea + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..93c2eae --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Summary + + + +## Runtime impact + +- [ ] Android legacy architecture +- [ ] Android New Architecture +- [ ] iOS legacy architecture +- [ ] iOS New Architecture +- [ ] React Native Web +- [ ] Documentation or tooling only + +## Verification + + + +## Checklist + +- [ ] Public API changes include TypeScript declarations and bilingual docs. +- [ ] Native C changes include malformed-input coverage and a rebuilt WebAssembly bundle. +- [ ] Compatibility or behavior changes include focused regression tests. +- [ ] The packed npm artifact was inspected when package contents changed. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8fe96e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '03:00' + timezone: Asia/Shanghai + open-pull-requests-limit: 5 + groups: + development-dependencies: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: bundler + directory: /example + schedule: + interval: monthly + day: monday + time: '03:30' + timezone: Asia/Shanghai + open-pull-requests-limit: 3 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + day: monday + time: '04:00' + timezone: Asia/Shanghai + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cda5f40..3d0e3b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,16 @@ jobs: site=true web=true ;; - src/**|cpp/**|assets/**) + src/**|assets/**) + android=true + ios=true + quality=true + web=true + ;; + cpp/fuzz/**|scripts/test-native-fuzz.sh) + quality=true + ;; + cpp/**) android=true ios=true quality=true @@ -71,6 +80,10 @@ jobs: android=true quality=true ;; + compatibility/android-api/**|scripts/test-rn-android-compatibility.sh) + android=true + quality=true + ;; ios/**|example/ios/**|*.podspec) ios=true quality=true @@ -80,14 +93,28 @@ jobs: ios=true quality=true ;; - web/**|scripts/build-web-wasm.sh|scripts/test-web*.mjs|scripts/web-*) + web/**|fixtures/**|scripts/build-web-wasm.sh|scripts/test-web*.mjs|scripts/web-*) + android=true + ios=true web=true quality=true site=true ;; + scripts/prepare-package.mjs|scripts/test-package-consumers.mjs) + quality=true + web=true + ;; + benchmarks/**|scripts/benchmark-web.mjs) + quality=true + site=true + web=true + ;; site/**|docs/**|README*.md|CONTRIBUTING.md|scripts/build-site.mjs|scripts/test-site*.mjs|.github/workflows/pages.yml) site=true ;; + .github/ISSUE_TEMPLATE/**|.github/PULL_REQUEST_TEMPLATE.md|.github/CODEOWNERS|.github/dependabot.yml) + quality=true + ;; *.md) site=true ;; @@ -238,6 +265,36 @@ jobs: if-no-files-found: ignore retention-days: 7 + android-rn-compatibility: + name: Android RN API Compatibility (${{ matrix.react-native }}) + needs: changes + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: true + max-parallel: 2 + matrix: + react-native: ['0.73.11', '0.74.7', '0.86.0'] + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: zulu + java-version: 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + cache-provider: basic + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Compile library against React Native Android APIs + run: sh scripts/test-rn-android-compatibility.sh ${{ matrix.react-native }} new + ios-build-test: name: iOS (newArch=${{ matrix.new-arch }}) needs: changes @@ -331,6 +388,7 @@ jobs: yarn test:web yarn test:web:browser yarn test:web:metro + yarn test:package - name: Verify npm package contents run: npm pack --dry-run --ignore-scripts @@ -354,6 +412,7 @@ jobs: yarn typecheck yarn lint yarn test --runInBand + FUZZ_RUNS=2000 yarn test:fuzz site-test: name: Documentation Site and Playground Test @@ -380,7 +439,17 @@ jobs: ci-complete: name: Complete CI - needs: [changes, quality, android-build, android-api-level-test, ios-build-test, web-test, site-test] + needs: + [ + changes, + quality, + android-build, + android-api-level-test, + android-rn-compatibility, + ios-build-test, + web-test, + site-test, + ] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e97eba4..ce70662 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -81,9 +81,11 @@ jobs: yarn typecheck yarn lint yarn test --runInBand + FUZZ_RUNS=2000 yarn test:fuzz yarn test:web yarn test:web:browser yarn test:web:metro + yarn test:package npm pack --dry-run --ignore-scripts env: CHROME_PATH: /usr/bin/google-chrome diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5439eca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +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.1.0](https://github.com/JimmyDaddy/react-native-bs-diff-patch/releases/tag/v0.1.0) (2026-07-18) + +### Features + +- add React Native Web support backed by WebAssembly and module Workers; +- support both the React Native legacy and New Architecture runtimes; +- add Android and iOS device-level runtime assertions; +- publish through npm Trusted Publishing with provenance; +- add bilingual documentation, an interactive Playground, and GitHub Pages. diff --git a/README.md b/README.md index 07348cb..552294d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ all use the compatible `ENDSLEY/BSDIFF43` wire format. - **One patch format:** generate on one supported runtime and apply on another. - **Both React Native architectures:** legacy bridge and TurboModule/New Architecture. - **Responsive by default:** native work uses dedicated serial queues; Web work - runs in an isolated module Worker. + reuses a module Worker and cached WebAssembly instance off the page thread. +- **Bound untrusted Web work:** built-in cancellation and input/output byte + limits reject predictably with stable error codes. - **No Web service required:** the browser implementation is the same bundled C core compiled to WebAssembly. @@ -76,11 +78,20 @@ success. ```ts import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; -export async function webRoundTrip(oldFile: File, newFile: File) { +export async function webRoundTrip( + oldFile: File, + newFile: File, + signal?: AbortSignal +) { const oldData = await oldFile.arrayBuffer(); const newData = await newFile.arrayBuffer(); - const patchData = await diffBytes(oldData, newData); - const restoredData = await patchBytes(oldData, patchData); + const options = { + signal, + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 64 * 1024 * 1024, + }; + const patchData = await diffBytes(oldData, newData, options); + const restoredData = await patchBytes(oldData, patchData, options); return { patchData, restoredData }; } @@ -88,18 +99,19 @@ export async function webRoundTrip(oldFile: File, newFile: File) { `diffBytes` and `patchBytes` accept `ArrayBuffer`, any `ArrayBufferView` (including typed arrays and `DataView`), or `Blob`. They resolve to a new -`Uint8Array` and leave the caller's buffers usable. +`Uint8Array` and leave the caller's buffers usable. Aborted operations reject +with `EABORTED`; configured size limits reject with `ERESOURCE`. ## Platform API matrix -| API | Android | iOS | Web | -| --------------------------------------- | ------- | --- | --- | -| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No | -| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No | -| `diffBytes(oldData, newData)` | No | No | Yes | -| `patchBytes(oldData, patchData)` | No | No | Yes | -| Legacy architecture | 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 | +| `diffBytes(oldData, newData, options?)` | No | No | Yes | +| `patchBytes(oldData, patchData, options?)` | No | No | Yes | +| Legacy architecture | Yes | Yes | N/A | +| New Architecture / TurboModule | Yes | Yes | N/A | Calling an API family that is unavailable on the current platform rejects with `EUNSUPPORTED` instead of silently choosing different behavior. @@ -117,6 +129,11 @@ Calling an API family that is unavailable on the current platform rejects with See [Production recipes](./docs/recipes.md) for error handling, downloads, cross-runtime patch exchange, and integrity checks. +CI directly compiles the Android New Architecture sources against React Native +0.73.11, 0.74.7, and 0.86.0. Packed-consumer tests also verify that browser, +ESM, CommonJS, and TypeScript resolution work without installing optional React +Native peers for Web-only consumers. + ## Documentation - [Getting started](./docs/getting-started.md) @@ -130,7 +147,8 @@ cross-runtime patch exchange, and integrity checks. ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for the local workflow and quality -gates. +gates. Release history is in [CHANGELOG.md](./CHANGELOG.md); security reports +follow [SECURITY.md](./SECURITY.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index de8eafc..39d7aa7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,9 @@ Android、iOS 与 React Native Web 共用兼容的 `ENDSLEY/BSDIFF43` 补丁格 - **统一补丁格式:** 可以在一个受支持的运行时生成补丁,在另一个运行时应用。 - **兼容 RN 两种架构:** 同时支持旧桥接架构和 TurboModule / 新架构。 -- **默认不阻塞 UI:** 原生端使用专用串行队列,Web 端使用独立模块 Worker。 +- **默认不阻塞 UI:** 原生端使用专用串行队列;Web 端复用模块 Worker 和已初始化的 + WebAssembly 实例,让计算离开页面线程。 +- **限制不可信 Web 任务:** 内置取消与输入/输出字节上限,并提供稳定错误码。 - **Web 无需后端服务:** 浏览器直接运行由同一套 C 核心编译而来的 WebAssembly。 | 运行时 | 输入方式 | 生成补丁 | 应用补丁 | @@ -71,11 +73,20 @@ export async function nativeRoundTrip({ ```ts import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; -export async function webRoundTrip(oldFile: File, newFile: File) { +export async function webRoundTrip( + oldFile: File, + newFile: File, + signal?: AbortSignal +) { const oldData = await oldFile.arrayBuffer(); const newData = await newFile.arrayBuffer(); - const patchData = await diffBytes(oldData, newData); - const restoredData = await patchBytes(oldData, patchData); + const options = { + signal, + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 64 * 1024 * 1024, + }; + const patchData = await diffBytes(oldData, newData, options); + const restoredData = await patchBytes(oldData, patchData, options); return { patchData, restoredData }; } @@ -83,18 +94,19 @@ export async function webRoundTrip(oldFile: File, newFile: File) { `diffBytes` 和 `patchBytes` 接受 `ArrayBuffer`、任意 `ArrayBufferView` (包括 TypedArray 和 `DataView`)或 `Blob`。它们返回新的 `Uint8Array`,且不会 -转移或失效调用方传入的缓冲区。 +转移或失效调用方传入的缓冲区。取消以 `EABORTED` 拒绝,命中配置的大小上限时以 +`ERESOURCE` 拒绝。 ## 平台能力矩阵 -| API | Android | iOS | Web | -| --------------------------------------- | ------- | ------ | ------ | -| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 | -| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 | -| `diffBytes(oldData, newData)` | 不支持 | 不支持 | 支持 | -| `patchBytes(oldData, patchData)` | 不支持 | 不支持 | 支持 | -| 旧架构 | 支持 | 支持 | 不适用 | -| 新架构 / TurboModule | 支持 | 支持 | 不适用 | +| API | Android | iOS | Web | +| ------------------------------------------ | ------- | ------ | ------ | +| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 | +| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 | +| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 | +| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 | +| 旧架构 | 支持 | 支持 | 不适用 | +| 新架构 / TurboModule | 支持 | 支持 | 不适用 | 调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他行为。 @@ -110,6 +122,10 @@ export async function webRoundTrip(oldFile: File, newFile: File) { 错误处理、补丁下载、跨运行时交换和完整性校验示例见 [生产实践](./docs/zh-CN/recipes.md)。 +CI 会直接使用 React Native 0.73.11、0.74.7 与 0.86.0 编译 Android 新架构 +源码。真实 npm tarball 消费测试还会验证 browser、ESM、CommonJS 与 TypeScript +解析,并确保仅使用 Web 的消费者不会被强制安装可选 React Native peer。 + ## 完整文档 - [快速开始](./docs/zh-CN/getting-started.md) @@ -122,7 +138,8 @@ export async function webRoundTrip(oldFile: File, newFile: File) { ## 参与贡献 -本地开发流程和质量门禁见 [CONTRIBUTING.md](./CONTRIBUTING.md)。 +本地开发流程和质量门禁见 [CONTRIBUTING.md](./CONTRIBUTING.md)。发布记录见 +[CHANGELOG.md](./CHANGELOG.md),安全问题报告流程见 [SECURITY.md](./SECURITY.md)。 ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..177ccc0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security policy + +## Supported versions + +Security fixes are provided for the latest published minor release. + +| Version | Supported | +| ------- | --------- | +| Latest | Yes | +| Older | No | + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. Use +[GitHub private vulnerability reporting](https://github.com/JimmyDaddy/react-native-bs-diff-patch/security/advisories/new) +and include the affected version, platform, architecture mode, reproduction +steps, and impact. + +You should receive an acknowledgement within 3 business days. We will confirm +the assessment and planned disclosure timeline after reproducing the report. + +## Patch trust boundary + +This library validates its patch format and rejects malformed inputs, but it +does not authenticate patches. Applications distributing remote patches must +verify a trusted signature or digest before applying a patch and verify the +restored output before replacing application data. Use the Web resource limits +for untrusted browser inputs and enforce equivalent product-specific limits +around native file operations. diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt index 2bf283e..e684827 100644 --- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt +++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt @@ -29,10 +29,14 @@ internal object BsDiffPatchNative { throw BsDiffPatchException("EEXIST", "newFile: $newFile already exists") } - return bsPatchFile( - oldFileObj.absolutePath, - newFileObj.absolutePath, - patchFileObj.absolutePath + return requireSuccess( + "EPATCH", + "patch", + bsPatchFile( + oldFileObj.absolutePath, + newFileObj.absolutePath, + patchFileObj.absolutePath + ) ) } @@ -56,13 +60,24 @@ internal object BsDiffPatchNative { throw BsDiffPatchException("EEXIST", "patchFile: $patchFile already exists") } - return bsDiffFile( - oldFileObj.absolutePath, - newFileObj.absolutePath, - patchFileObj.absolutePath + return requireSuccess( + "EDIFF", + "diff", + bsDiffFile( + oldFileObj.absolutePath, + newFileObj.absolutePath, + patchFileObj.absolutePath + ) ) } + private fun requireSuccess(code: String, operation: String, result: Int): Int { + if (result != 0) { + throw BsDiffPatchException(code, "$operation failed with native result $result") + } + return result + } + private fun validateNonEmpty(value: String, fieldName: String) { if (value.isEmpty()) { throw BsDiffPatchException("EINVAL", "$fieldName can not be null or empty") diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..aa2cd48 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,15 @@ +# Benchmarks + +`web-wasm.json` is a reproducible reference measurement for the checked-in +WebAssembly implementation. It is not a performance guarantee: file contents, +hardware, browser scheduling, and available memory can change results. + +Regenerate the 1, 10, and 50 MiB workload with: + +```sh +BENCHMARK_OUTPUT=benchmarks/web-wasm.json yarn benchmark:web +``` + +The workload changes one byte per 4 KiB and verifies every restored byte. The +benchmark measures the WebAssembly core after a small initialization warm-up; +the public browser API additionally transfers data through a module Worker. diff --git a/benchmarks/web-wasm.json b/benchmarks/web-wasm.json new file mode 100644 index 0000000..76cafa3 --- /dev/null +++ b/benchmarks/web-wasm.json @@ -0,0 +1,32 @@ +{ + "generatedAt": "2026-07-19T01:33:43.029Z", + "runtime": { + "cpu": "Apple M3 Pro", + "node": "v26.5.0", + "platform": "darwin-arm64" + }, + "workload": { + "description": "Deterministic buffers with one changed byte per 4 KiB", + "initializationMs": 14.6 + }, + "results": [ + { + "sizeMiB": 1, + "diffMs": 158.5, + "patchMs": 7.7, + "patchBytes": 110 + }, + { + "sizeMiB": 10, + "diffMs": 4243.6, + "patchMs": 57.5, + "patchBytes": 118 + }, + { + "sizeMiB": 50, + "diffMs": 30697.5, + "patchMs": 285.2, + "patchBytes": 203 + } + ] +} diff --git a/compatibility/android-api/build.gradle b/compatibility/android-api/build.gradle new file mode 100644 index 0000000..5d32d09 --- /dev/null +++ b/compatibility/android-api/build.gradle @@ -0,0 +1,61 @@ +plugins { + id "com.android.library" version "8.1.4" + id "org.jetbrains.kotlin.android" +} + +def reactNativeVersion = providers.gradleProperty("reactNativeVersion").get() +def architecture = providers.gradleProperty("architecture").orElse("new").get() +def versionMatch = reactNativeVersion =~ /^(\d+)\.(\d+)\./ + +if (!versionMatch.find()) { + throw new GradleException("Unsupported React Native version: ${reactNativeVersion}") +} + +def reactNativeMinor = versionMatch.group(2).toInteger() +def repositoryRoot = file("../..").canonicalFile +def libraryAndroidRoot = new File(repositoryRoot, "android") +def librarySourceDirectories = [ + new File(libraryAndroidRoot, "src/main/java") +] + +if (architecture == "new") { + librarySourceDirectories += new File(libraryAndroidRoot, "newarch/java") + librarySourceDirectories += new File( + libraryAndroidRoot, + reactNativeMinor >= 74 ? "newarch74/java" : "newarch73/java" + ) + librarySourceDirectories += file("src/newarchStubs/kotlin") +} else if (architecture == "old") { + librarySourceDirectories += new File(libraryAndroidRoot, "oldarch/java") +} else { + throw new GradleException("Unsupported architecture: ${architecture}") +} + +android { + namespace "com.jimmydaddy.bsdiffpatch.compatibility" + compileSdk 36 + + defaultConfig { + minSdk 24 + } + + sourceSets { + main { + java.srcDirs = librarySourceDirectories + manifest.srcFile "src/main/AndroidManifest.xml" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + compileOnly "com.facebook.react:react-android:${reactNativeVersion}" +} diff --git a/compatibility/android-api/gradle.properties b/compatibility/android-api/gradle.properties new file mode 100644 index 0000000..a20a517 --- /dev/null +++ b/compatibility/android-api/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.suppressUnsupportedCompileSdk=36 +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 diff --git a/compatibility/android-api/settings.gradle b/compatibility/android-api/settings.gradle new file mode 100644 index 0000000..4d8f684 --- /dev/null +++ b/compatibility/android-api/settings.gradle @@ -0,0 +1,21 @@ +pluginManagement { + plugins { + id "org.jetbrains.kotlin.android" version providers.gradleProperty("kotlinVersion").orElse("1.9.22").get() + } + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "react-native-bs-diff-patch-android-api-compatibility" diff --git a/compatibility/android-api/src/main/AndroidManifest.xml b/compatibility/android-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/compatibility/android-api/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/compatibility/android-api/src/newarchStubs/kotlin/com/jimmydaddy/bsdiffpatch/NativeBsDiffPatchSpec.kt b/compatibility/android-api/src/newarchStubs/kotlin/com/jimmydaddy/bsdiffpatch/NativeBsDiffPatchSpec.kt new file mode 100644 index 0000000..54a22de --- /dev/null +++ b/compatibility/android-api/src/newarchStubs/kotlin/com/jimmydaddy/bsdiffpatch/NativeBsDiffPatchSpec.kt @@ -0,0 +1,23 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule + +abstract class NativeBsDiffPatchSpec( + reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext) { + abstract fun patch( + oldFile: String, + newFile: String, + patchFile: String, + promise: Promise + ) + + abstract fun diff( + oldFile: String, + newFile: String, + patchFile: String, + promise: Promise + ) +} diff --git a/cpp/bsdiff.c b/cpp/bsdiff.c index abe7f3a..d35f30f 100644 --- a/cpp/bsdiff.c +++ b/cpp/bsdiff.c @@ -328,6 +328,13 @@ int bsdiff(const uint8_t* oldBuf, int64_t oldsize, const uint8_t* newBuf, int64_ int result; struct bsdiff_request req; + if (oldBuf == NULL || newBuf == NULL || stream == NULL || + stream->malloc == NULL || stream->free == NULL || stream->write == NULL || + oldsize < 0 || newsize < 0 || + (uint64_t)oldsize > (SIZE_MAX / sizeof(int64_t)) - 1 || + (uint64_t)newsize > SIZE_MAX - 1) + return -1; + if((req.I=stream->malloc((oldsize+1)*sizeof(int64_t)))==NULL) return -1; @@ -385,66 +392,103 @@ static int bz2_write(struct bsdiff_stream* stream, const void* buffer, int size) int bsDiffFile(const char* oldFile, const char* newFile, const char* patchFile) { - int fd; + int fd = -1; int bz2err; - uint8_t *old,*new; - off_t oldsize,newsize; + int closeResult; + int outputCreated = 0; + int result = -1; + uint8_t *old = NULL, *new = NULL; + int64_t oldsize = 0, newsize = 0; + off_t measuredSize; uint8_t buf[8]; - FILE * pf; + FILE * pf = NULL; struct bsdiff_stream stream; - BZFILE* bz2; + BZFILE* bz2 = NULL; - memset(&bz2, 0, sizeof(bz2)); stream.malloc = malloc; stream.free = free; stream.write = bz2_write; - /* Allocate oldsize+1 bytes instead of oldsize bytes to ensure - that we never try to malloc(0) and get a NULL pointer */ - if(((fd=open(oldFile,O_RDONLY,0))<0) || - ((oldsize=lseek(fd,0,SEEK_END))==-1) || - ((old=malloc(oldsize+1))==NULL) || - (lseek(fd,0,SEEK_SET)!=0) || - (readFileToBuffer(fd,old,oldsize)!=oldsize) || - (close(fd)==-1)) err(1,"%s",oldFile); - - /* Allocate newsize+1 bytes instead of newsize bytes to ensure - that we never try to malloc(0) and get a NULL pointer */ - if(((fd=open(newFile,O_RDONLY,0))<0) || - ((newsize=lseek(fd,0,SEEK_END))==-1) || - ((new=malloc(newsize+1))==NULL) || - (lseek(fd,0,SEEK_SET)!=0) || - (readFileToBuffer(fd,new,newsize)!=newsize) || - (close(fd)==-1)) err(1,"%s",newFile); + if (oldFile == NULL || newFile == NULL || patchFile == NULL) + goto cleanup; + + 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; + old = malloc((size_t)oldsize + 1); + if (old == NULL || lseek(fd, 0, SEEK_SET) != 0 || + readFileToBuffer(fd, old, (off_t)oldsize) != (off_t)oldsize) + goto cleanup; + closeResult = close(fd); + fd = -1; + if (closeResult != 0) + goto cleanup; + + fd = open(newFile, 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; + newsize = (int64_t)measuredSize; + new = malloc((size_t)newsize + 1); + if (new == NULL || lseek(fd, 0, SEEK_SET) != 0 || + readFileToBuffer(fd, new, (off_t)newsize) != (off_t)newsize) + goto cleanup; + closeResult = close(fd); + fd = -1; + if (closeResult != 0) + goto cleanup; /* Create the patch file */ - if ((pf = fopen(patchFile, "w")) == NULL) - err(1, "%s", patchFile); + fd = open(patchFile, O_CREAT|O_EXCL|O_WRONLY, 0666); + if (fd < 0) + goto cleanup; + outputCreated = 1; + pf = fdopen(fd, "wb"); + if (pf == NULL) + goto cleanup; + fd = -1; /* Write header (signature+newsize)*/ offtout(newsize, buf); if (fwrite("ENDSLEY/BSDIFF43", 16, 1, pf) != 1 || fwrite(buf, sizeof(buf), 1, pf) != 1) - err(1, "Failed to write header"); - + goto cleanup; - if (NULL == (bz2 = BZ2_bzWriteOpen(&bz2err, pf, 9, 0, 0))) - errx(1, "BZ2_bzWriteOpen, bz2err=%d", bz2err); + bz2 = BZ2_bzWriteOpen(&bz2err, pf, 9, 0, 0); + if (bz2 == NULL || bz2err != BZ_OK) + goto cleanup; stream.opaque = bz2; if (bsdiff(old, oldsize, new, newsize, &stream)) - err(1, "bsdiff"); + goto cleanup; BZ2_bzWriteClose(&bz2err, bz2, 0, NULL, NULL); + bz2 = NULL; if (bz2err != BZ_OK) - err(1, "BZ2_bzWriteClose, bz2err=%d", bz2err); - - if (fclose(pf)) - err(1, "fclose"); - - /* Free the memory we used */ + goto cleanup; + + closeResult = fclose(pf); + pf = NULL; + if (closeResult != 0) + goto cleanup; + result = 0; + +cleanup: + if (bz2 != NULL) + BZ2_bzWriteClose(&bz2err, bz2, 1, NULL, NULL); + if (pf != NULL) + fclose(pf); + if (fd >= 0) + close(fd); + if (result != 0 && outputCreated && patchFile != NULL) + unlink(patchFile); free(old); free(new); - - return 0; + return result; } diff --git a/cpp/bsdiff.h b/cpp/bsdiff.h index 01c22d7..9809e1a 100644 --- a/cpp/bsdiff.h +++ b/cpp/bsdiff.h @@ -38,7 +38,6 @@ #endif #include -#include #include #include #include diff --git a/cpp/bspatch.c b/cpp/bspatch.c index 7c4c748..bb9040b 100644 --- a/cpp/bspatch.c +++ b/cpp/bspatch.c @@ -46,13 +46,27 @@ static int64_t offtin(uint8_t *buf) return y; } +static int checked_add_int64(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; +} + int bspatch(const uint8_t* oldbuf, int64_t oldsize, uint8_t* newbuf, int64_t newsize, struct bspatch_stream* stream) { uint8_t buf[8]; int64_t oldpos,newpos; + int64_t nextoldpos; int64_t ctrl[3]; int64_t i; + if (oldbuf == NULL || newbuf == NULL || stream == NULL || + stream->read == NULL || oldsize < 0 || newsize < 0) + return -1; + oldpos=0;newpos=0; while(newposINT_MAX || ctrl[1]<0 || ctrl[1]>INT_MAX || - newpos+ctrl[0]>newsize) + ctrl[0]>newsize-newpos || + checked_add_int64(oldpos, ctrl[0], &nextoldpos)) return -1; /* Read diff string */ @@ -79,10 +94,10 @@ int bspatch(const uint8_t* oldbuf, int64_t oldsize, uint8_t* newbuf, int64_t new /* Adjust pointers */ newpos+=ctrl[0]; - oldpos+=ctrl[0]; + oldpos=nextoldpos; /* Sanity-check */ - if(newpos+ctrl[1]>newsize) + if(ctrl[1]>newsize-newpos) return -1; /* Read extra string */ @@ -91,7 +106,9 @@ int bspatch(const uint8_t* oldbuf, int64_t oldsize, uint8_t* newbuf, int64_t new /* Adjust pointers */ newpos+=ctrl[1]; - oldpos+=ctrl[2]; + if (checked_add_int64(oldpos, ctrl[2], &nextoldpos)) + return -1; + oldpos=nextoldpos; }; return 0; @@ -151,71 +168,100 @@ static off_t writeFileFromBuffer(int fd, uint8_t* buffer, off_t bufferSize) int bsPatchFile(const char* oldFile, const char* newFile, const char* patchFile) { - FILE * f; - int fd; + FILE * f = NULL; + int fd = -1; int bz2err; + int closeResult; + int result = -1; + int outputCreated = 0; uint8_t header[24]; - uint8_t *old, *new; - int64_t oldsize, newsize; - BZFILE* bz2; + uint8_t *old = NULL, *new = NULL; + int64_t oldsize = 0, newsize = 0; + off_t measuredSize; + BZFILE* bz2 = NULL; struct bspatch_stream stream; - struct stat sb; + + if (oldFile == NULL || newFile == NULL || patchFile == NULL) + goto cleanup; /* Open patch file */ - if ((f = fopen(patchFile, "r")) == NULL) { - printf ("Cannot open file %s \n", patchFile); - err(1, "fopen(%s)", patchFile); - } + f = fopen(patchFile, "rb"); + if (f == NULL) + goto cleanup; /* Read header */ - if (fread(header, 1, 24, f) != 24) { - if (feof(f)) { - printf ("Corrupt patch %s \n", patchFile); - errx(1, "Corrupt patch\n"); - } - err(1, "fread(%s)", patchFile); - } + if (fread(header, 1, 24, f) != 24) + goto cleanup; /* Check for appropriate magic */ - if (memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0) { - errx(1, "Corrupt patch\n"); - } + if (memcmp(header, "ENDSLEY/BSDIFF43", 16) != 0) + goto cleanup; /* Read lengths from header */ newsize=offtin(header+16); - if(newsize<0) { - errx(1,"Corrupt patch\n"); - } + if(newsize < 0 || (uint64_t)newsize > SIZE_MAX - 1) + goto cleanup; /* Close patch file and re-open it via libbzip2 at the right places */ - if(((fd=open(oldFile,O_RDONLY,0))<0) || - ((oldsize=lseek(fd,0,SEEK_END))==-1) || - ((old=malloc(oldsize+1))==NULL) || - (lseek(fd,0,SEEK_SET)!=0) || - (readFileToBuffer(fd,old,oldsize)!=oldsize) || - (fstat(fd, &sb)) || - (close(fd)==-1)) err(1,"%s", oldFile); - if((new=malloc(newsize+1))==NULL) err(1,NULL); - if (NULL == (bz2 = BZ2_bzReadOpen(&bz2err, f, 0, 1, NULL, 0))) { - errx(1, "BZ2_bzReadOpen, bz2err=%d", bz2err); - } + 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; + old = malloc((size_t)oldsize + 1); + if (old == NULL || lseek(fd, 0, SEEK_SET) != 0 || + readFileToBuffer(fd, old, (off_t)oldsize) != (off_t)oldsize) + goto cleanup; + closeResult = close(fd); + fd = -1; + if (closeResult != 0) + goto cleanup; + + 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; stream.read = bz2_read; stream.opaque = bz2; - if (bspatch(old, oldsize, new, newsize, &stream)) { - errx(1, "bspatch"); - } + if (bspatch(old, oldsize, new, newsize, &stream)) + goto cleanup; /* Clean up the bzip2 reads */ BZ2_bzReadClose(&bz2err, bz2); - fclose(f); + bz2 = NULL; + closeResult = fclose(f); + f = NULL; + if (closeResult != 0) + goto cleanup; /* Write the new file */ - if(((fd=open(newFile,O_CREAT|O_TRUNC|O_WRONLY,0666))<0) || - (writeFileFromBuffer(fd,new,newsize)!=newsize) || (close(fd)==-1)) { - err(1,"%s",newFile); - } + fd = open(newFile, O_CREAT|O_EXCL|O_WRONLY, 0666); + if (fd < 0) + goto cleanup; + outputCreated = 1; + if (writeFileFromBuffer(fd, new, (off_t)newsize) != (off_t)newsize) + goto cleanup; + closeResult = close(fd); + fd = -1; + if (closeResult != 0) + goto cleanup; + result = 0; + +cleanup: + if (bz2 != NULL) + BZ2_bzReadClose(&bz2err, bz2); + if (f != NULL) + fclose(f); + if (fd >= 0) + close(fd); + if (result != 0 && outputCreated && newFile != NULL) + unlink(newFile); free(new); free(old); - return 0; + return result; } diff --git a/cpp/bspatch.h b/cpp/bspatch.h index f83e490..04b8c75 100644 --- a/cpp/bspatch.h +++ b/cpp/bspatch.h @@ -38,7 +38,6 @@ #include #include #include -#include #include #include #include diff --git a/cpp/fuzz/bspatch_fuzzer.c b/cpp/fuzz/bspatch_fuzzer.c new file mode 100644 index 0000000..362b8aa --- /dev/null +++ b/cpp/fuzz/bspatch_fuzzer.c @@ -0,0 +1,95 @@ +#include +#include +#include + +#ifdef BSDIFFPATCH_STANDALONE_FUZZ +#include +#include +#endif + +#include "bspatch.h" + +struct fuzz_input { + const uint8_t *data; + size_t size; + size_t offset; +}; + +static int fuzz_read( + const struct bspatch_stream *stream, + void *buffer, + int length) +{ + struct fuzz_input *input = (struct fuzz_input *)stream->opaque; + + if (length < 0 || (size_t)length > input->size - input->offset) + return -1; + + memcpy(buffer, input->data + input->offset, (size_t)length); + input->offset += (size_t)length; + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + uint8_t old_buffer[64] = {0}; + uint8_t new_buffer[64] = {0}; + struct fuzz_input input; + struct bspatch_stream stream; + size_t old_size; + size_t new_size; + size_t copied_old_size; + + if (size < 2) + return 0; + + old_size = data[0] % sizeof(old_buffer); + new_size = data[1] % sizeof(new_buffer); + copied_old_size = old_size < size - 2 ? old_size : size - 2; + memcpy(old_buffer, data + 2, copied_old_size); + + input.data = data + 2 + copied_old_size; + input.size = size - 2 - copied_old_size; + input.offset = 0; + stream.opaque = &input; + stream.read = fuzz_read; + + (void)bspatch( + old_buffer, + (int64_t)old_size, + new_buffer, + (int64_t)new_size, + &stream); + return 0; +} + +#ifdef BSDIFFPATCH_STANDALONE_FUZZ +static uint32_t next_random(uint32_t *state) +{ + uint32_t value = *state; + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + *state = value; + return value; +} + +int main(int argc, char **argv) +{ + uint8_t input[512]; + uint32_t state = 0x42534450u; + long runs = argc > 1 ? strtol(argv[1], NULL, 10) : 5000; + long run; + + for (run = 0; run < runs; run++) { + size_t size = (size_t)(next_random(&state) % sizeof(input)); + size_t index; + for (index = 0; index < size; index++) + input[index] = (uint8_t)next_random(&state); + LLVMFuzzerTestOneInput(input, size); + } + + printf("Standalone sanitizer fuzz passed %ld runs\n", runs); + return 0; +} +#endif diff --git a/docs/api-reference.md b/docs/api-reference.md index 8c1ac65..825e6e0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -10,6 +10,7 @@ import { diffBytes, patchBytes, type BinaryInput, + type BinaryOperationOptions, } from 'react-native-bs-diff-patch'; ``` @@ -56,9 +57,16 @@ Reconstructs the target file at `outputFile`. Available on Android and iOS. ```ts type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; +interface BinaryOperationOptions { + signal?: AbortSignal; + maxInputBytes?: number; + maxOutputBytes?: number; +} + function diffBytes( oldData: BinaryInput, - newData: BinaryInput + newData: BinaryInput, + options?: BinaryOperationOptions ): Promise; ``` @@ -67,13 +75,16 @@ 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. - 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. ## `patchBytes` ```ts function patchBytes( oldData: BinaryInput, - patchData: BinaryInput + patchData: BinaryInput, + options?: BinaryOperationOptions ): Promise; ``` @@ -83,6 +94,21 @@ bytes. Available on Web. - Validates the patch header before invoking the WebAssembly core. - Copies 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`. + +## Web operation options + +- `signal` cancels the current Web operation. A call with a signal receives a + 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. +- Limits must be non-negative safe integers. Invalid limits reject with + `EINVAL`; exceeded limits reject with `ERESOURCE`. + +The binary APIs accept the options argument on native only to keep shared +wrappers source-compatible, then reject with `EUNSUPPORTED` as usual. Native +resource policy remains the application's filesystem/workflow responsibility. ## Availability behavior @@ -110,6 +136,10 @@ type PatchError = Error & { code?: string }; | `EEXIST` | A native output path already exists. | | `EUNSUPPORTED` | The selected API is not available on the current platform. | | `EUNAVAILABLE` | The native module worker has already shut down. | +| `EABORTED` | The Web operation was cancelled through its signal. | +| `ERESOURCE` | A configured Web input or output byte limit was exceeded. | +| `EDIFF` | The native diff core rejected or could not write the input. | +| `EPATCH` | The native patch core rejected a malformed patch or output. | | `EWEBASSEMBLY` | WebAssembly loading, patch validation, or execution failed. | | `EUNSPECIFIED` | An unclassified native exception occurred. | @@ -122,10 +152,11 @@ Worker startup, patch validation, or WebAssembly execution use ## Concurrency and ordering -Each native platform uses a serial library-owned queue. Every Web call creates -an isolated module Worker and terminates it after completion. Do not rely on -operations completing in submission order across separate Web calls, and apply -an application-level concurrency limit for large browser inputs. +Each native platform uses a serial library-owned queue. Web calls without a +signal share one module Worker, a serialized request queue, and a cached +WebAssembly module. Calls with a signal use a dedicated Worker so cancellation +is operation-local. Apply an application-level concurrency and memory budget +for large browser inputs. ## Patch format diff --git a/docs/architecture.md b/docs/architecture.md index 65f1026..f4e4e98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ React Native JavaScript React Native Web -> typed public API - -> module Web Worker + -> shared or cancellation-scoped module Web Worker -> Emscripten MEMFS -> the same bsdiff + bzip2 C sources compiled to WebAssembly ``` @@ -24,6 +24,12 @@ The worker boundaries keep expensive binary work away from the JavaScript/UI thread. They do not make the algorithm free: callers remain responsible for product-specific input-size and time limits. +Web calls without an `AbortSignal` reuse one Worker and a cached Emscripten +module, avoiding repeated Worker and WebAssembly initialization. Calls with a +signal use a dedicated Worker; aborting the signal terminates only that Worker. +Every Worker serializes its own request queue and removes temporary MEMFS files +after each operation. + ## Patch wire format Patches begin with a 24-byte header: @@ -55,6 +61,32 @@ manifest when distributing patches. The generated `web/bsdiffpatch.mjs` is published with the package. Consumers do not need Emscripten. +## Compatibility verification + +A checked-in golden fixture proves that the Web implementation generates the +same deterministic patch bytes consumed by Android and iOS. Device runtime +tests also apply the golden Web patch and reject a truncated patch without +leaving partial output. The C patch core has sanitizer-backed malformed-input +fuzz coverage and never terminates the hosting process for invalid data. + +## Reference Web benchmark + +`yarn benchmark:web` runs deterministic one-byte-per-4-KiB changes and verifies +the restored result byte-for-byte. On an Apple M3 Pro with Node 26.5.0, the +checked-in 2026-07-19 reference recorded: + +| Input | Diff | Patch | Patch bytes | +| ------ | ----------- | -------- | ----------- | +| 1 MiB | 158.5 ms | 7.7 ms | 110 | +| 10 MiB | 4,243.6 ms | 57.5 ms | 118 | +| 50 MiB | 30,697.5 ms | 285.2 ms | 203 | + +These figures are a reproducible development baseline, not a device or browser +performance guarantee. Input similarity, CPU, browser, memory pressure, and +toolchain version materially affect results. The full machine-readable record +is in +[`benchmarks/web-wasm.json`](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/benchmarks/web-wasm.json). + ## Memory model Native operations read the old and target files into process memory. Web calls diff --git a/docs/development.md b/docs/development.md index 72507b0..158fd03 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,12 +31,37 @@ yarn test --runInBand yarn test:web yarn test:web:browser yarn test:web:metro +yarn test:package ``` - `test:web` checks the WebAssembly round trip and patch magic. - `test:web:browser` runs the public Worker API in Chrome. - `test:web:metro` proves Metro selects the `.web` entry rather than the native TurboModule facade. +- `test:package` installs the real tarball into a clean consumer and verifies + browser, ESM, CommonJS, TypeScript, and optional-peer behavior. + +## Native robustness and compatibility + +```sh +FUZZ_RUNS=2000 yarn test:fuzz +scripts/test-rn-android-compatibility.sh 0.73.11 new +scripts/test-rn-android-compatibility.sh 0.74.7 new +scripts/test-rn-android-compatibility.sh 0.86.0 new +``` + +The fuzz gate uses libFuzzer with AddressSanitizer and UndefinedBehaviorSanitizer +when the local Clang runtime provides it, otherwise it runs a deterministic +sanitizer corpus. The compatibility fixture compiles the actual Android module +sources against the selected React Native artifact instead of relying on +source-pattern assertions. + +Run the repeatable Web performance baseline with: + +```sh +yarn benchmark:web +BENCHMARK_OUTPUT=/tmp/web-wasm.json yarn benchmark:web +``` ## Site and documentation @@ -65,16 +90,19 @@ Commit the regenerated `web/bsdiffpatch.mjs` with the C source change. ## Native verification -Android CI builds both architecture modes and runs the New Architecture device -round trip on its emulator matrix. iOS CI uses the CocoaPods version locked in -the example Gemfile to build and test both legacy and New Architecture modes. +Android CI builds both architecture modes, directly compiles New Architecture +sources against React Native 0.73.11, 0.74.7, and 0.86.0, and runs the New +Architecture device round trip on its emulator matrix. iOS CI uses the CocoaPods +version locked in the example Gemfile to build and test both legacy and New +Architecture modes. Device tests include cross-platform golden patches and +malformed-patch rejection. For local example commands, see [CONTRIBUTING.md](../CONTRIBUTING.md). ## Publishing checklist 1. Run the core, Web, and site gates. -2. Inspect `npm pack --dry-run --ignore-scripts` and confirm `web/` is present. +2. Run `yarn test:package` and inspect `npm pack --dry-run --ignore-scripts`. 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 diff --git a/docs/getting-started.md b/docs/getting-started.md index d621057..80f585d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,8 +87,14 @@ const encoder = new TextEncoder(); const oldData = encoder.encode('version 1'); const newData = encoder.encode('version 2 with web support'); -const patchData = await diffBytes(oldData, newData); -const restoredData = await patchBytes(oldData, patchData); +const controller = new AbortController(); +const options = { + signal: controller.signal, + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, +}; +const patchData = await diffBytes(oldData, newData, options); +const restoredData = await patchBytes(oldData, patchData, options); const matches = restoredData.length === newData.length && @@ -102,6 +108,11 @@ if (!matches) { Inputs are copied before being transferred to the module Worker, so the caller's buffers remain usable. Each result is a new `Uint8Array`. +Call `controller.abort()` to reject the active operation with `EABORTED`. +Configured byte limits reject with `ERESOURCE`. Calls without a signal reuse a +shared Worker and WebAssembly instance; calls with a signal use a dedicated +Worker so aborting one operation cannot interrupt another. + ## Use browser files ```ts diff --git a/docs/platform-support.md b/docs/platform-support.md index 057f532..fd6a8b7 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -11,9 +11,11 @@ | Background execution | Serial executor | Serial dispatch queue | Module Web Worker | | Patch format | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | -The example application continuously exercises React Native 0.73. CI also -builds consumer fixtures against newer New Architecture package APIs, including -the Android package API split described below. +The example application continuously exercises React Native 0.73.2. A direct +Android source-compatibility matrix compiles the New Architecture integration +against React Native 0.73.11, 0.74.7, and 0.86.0. The regular Android build also +compiles the 0.73 legacy architecture. These are tested versions, not a promise +that every intermediate or future React Native release is compatible. ## Android @@ -49,6 +51,7 @@ The browser must support: - Module Web Workers. - `ArrayBuffer` and typed arrays. - `Blob.arrayBuffer()` when `Blob` inputs are used. +- `AbortController` when operation cancellation is used. Webpack and Vite understand the standard `new Worker(new URL(..., import.meta.url), { type: 'module' })` pattern. A Metro @@ -57,6 +60,11 @@ 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. +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. + ## Server-side rendering Importing the Web entry does not create a worker. Calling `diffBytes` or diff --git a/docs/recipes.md b/docs/recipes.md index c25a3c2..d7c6058 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -75,11 +75,34 @@ the input or output size. Before starting an operation: - reject input larger than the product's tested limit; - confirm sufficient local storage for native temporary outputs; - prevent unbounded simultaneous calls from user actions; -- expose cancellation at the surrounding workflow level when appropriate; +- pass an `AbortSignal` to Web operations when cancellation is appropriate; - move very large update generation to controlled backend infrastructure. -Native calls share a library-owned serial queue. Separate Web calls each create -their own Worker, so the application should limit Web concurrency explicitly. +Native calls share a library-owned serial queue. Web calls without a signal +reuse a shared Worker and WebAssembly instance. Signalled calls use dedicated +Workers so they can be terminated independently. The application should still +limit aggregate Web concurrency and memory explicitly. + +```ts +const controller = new AbortController(); +const options = { + signal: controller.signal, + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 64 * 1024 * 1024, +}; + +try { + const patchData = await diffBytes(oldData, newData, options); + const restoredData = await patchBytes(oldData, patchData, options); +} catch (error) { + if (isPatchError(error) && error.code === 'EABORTED') return; + if (isPatchError(error) && error.code === 'ERESOURCE') { + // Show the product's size-limit guidance. + return; + } + throw error; +} +``` ## Download a Web patch diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 94e3079..c117416 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,6 +7,8 @@ Start with the error `code`, then confirm the runtime and input model: | Native module cannot be found | Rebuild the installed native application | | `ENOENT`, `EEXIST`, `EINVAL` | Inspect path state before starting native work | | `EUNSUPPORTED` | Confirm that the correct API family was selected | +| `EABORTED`, `ERESOURCE` | Check Web cancellation and byte limits | +| `EDIFF`, `EPATCH` | Check native I/O and patch integrity | | Worker or `EWEBASSEMBLY` failure | Check emitted Web assets, CSP, and patch magic | | High memory use | Enforce input and concurrency limits | @@ -45,6 +47,14 @@ The path-based `diff` and `patch` APIs are native-only. Use `diffBytes` and `patchBytes` in React Native Web. If a binary API reports that Web Workers are required, call it in browser/client code rather than during SSR. +## `EABORTED` or `ERESOURCE` + +`EABORTED` means the `AbortSignal` supplied to a Web operation was already +aborted or became aborted while its dedicated Worker was active. `ERESOURCE` +means an input, generated patch, or declared restored output exceeded the +configured byte limit. Both are expected control-flow errors rather than a +WebAssembly failure. + ## Worker failed to load Confirm the bundler emits module-worker assets and that the deployed server @@ -55,20 +65,26 @@ 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. -## `EWEBASSEMBLY` or corrupt patch +## `EPATCH`, `EWEBASSEMBLY`, or corrupt patch Check the first 16 bytes of the patch. Supported patches begin with `ENDSLEY/BSDIFF43`. A truncated patch, a `BSDIFF40` patch, or unrelated binary data will be rejected. +Native corrupt-patch failures use `EPATCH`; native patch-generation failures +use `EDIFF`. Web validation and C-core failures use `EWEBASSEMBLY` unless a +resource limit supplied the more specific `ERESOURCE` code. Failed native calls +remove any partial output owned by that operation. + ## High memory use The algorithm and adapters operate on complete in-memory buffers. Add a size check before calling the library and avoid accepting arbitrary large untrusted files. Web execution is off-main-thread but still consumes the tab's memory. -Multiple Web operations create separate Workers. Debounce repeated user actions -and add an application-level queue if large calls can overlap. +Unsignalled Web operations queue through a shared Worker. Signalled operations +use dedicated Workers for isolated cancellation. Debounce repeated user actions +and add an application-level budget if large calls can overlap. ## Restored output does not match diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index b5684d5..c912c01 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -10,6 +10,7 @@ import { diffBytes, patchBytes, type BinaryInput, + type BinaryOperationOptions, } from 'react-native-bs-diff-patch'; ``` @@ -53,9 +54,16 @@ function patch( ```ts type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; +interface BinaryOperationOptions { + signal?: AbortSignal; + maxInputBytes?: number; + maxOutputBytes?: number; +} + function diffBytes( oldData: BinaryInput, - newData: BinaryInput + newData: BinaryInput, + options?: BinaryOperationOptions ): Promise; ``` @@ -64,13 +72,16 @@ function diffBytes( - 接受 `ArrayBuffer`、任意 TypedArray、`DataView` 和 `Blob`。 - 会复制输入,不会让调用方缓冲区失效。 - 返回包含 `ENDSLEY/BSDIFF43` 补丁的新 `Uint8Array`。 +- 配置上限后,分别用 `maxInputBytes` 检查每个输入,并用 `maxOutputBytes` + 检查生成补丁。 ## `patchBytes` ```ts function patchBytes( oldData: BinaryInput, - patchData: BinaryInput + patchData: BinaryInput, + options?: BinaryOperationOptions ): Promise; ``` @@ -79,6 +90,19 @@ function patchBytes( - 进入 WebAssembly 核心前会校验补丁头。 - 复制输入并返回新的 `Uint8Array`。 - 不会修改 `oldData` 或 `patchData`。 +- 当补丁头声明的输出超过 `maxOutputBytes` 时,会在分配输出前拒绝。 + +## Web 操作选项 + +- `signal` 取消当前 Web 操作。带 signal 的调用使用专用 Worker,因此取消不会中断 + 其他请求。 +- `maxInputBytes` 分别限制每个二进制输入,而不是输入之和。 +- `maxOutputBytes` 限制生成补丁或还原输出。 +- 上限必须是非负安全整数;非法上限以 `EINVAL` 拒绝,超过上限以 `ERESOURCE` + 拒绝。 + +原生端的二进制 API 接受 options 参数只是为了让共享封装保持源码兼容,随后仍会以 +`EUNSUPPORTED` 拒绝。原生资源策略由应用的文件系统与外围流程负责。 ## 平台不可用时的行为 @@ -103,6 +127,10 @@ type PatchError = Error & { code?: string }; | `EEXIST` | 原生端输出路径已经存在。 | | `EUNSUPPORTED` | 当前平台不支持所选 API。 | | `EUNAVAILABLE` | 原生模块工作队列已经关闭。 | +| `EABORTED` | Web 操作被传入的 signal 取消。 | +| `ERESOURCE` | 超过配置的 Web 输入或输出字节上限。 | +| `EDIFF` | 原生 diff 核心拒绝输入或无法写入补丁。 | +| `EPATCH` | 原生 patch 核心拒绝损坏补丁或输出失败。 | | `EWEBASSEMBLY` | Worker、补丁校验或 WebAssembly 执行失败。 | | `EUNSPECIFIED` | 未分类的原生异常。 | @@ -110,8 +138,9 @@ type PatchError = Error & { code?: string }; ## 并发与顺序 -每个原生平台使用库内部的串行队列。每次 Web 调用会创建独立模块 Worker,并在完成后 -终止。不要假设不同 Web 调用会按提交顺序结束;对大输入应设置应用级并发限制。 +每个原生平台使用库内部的串行队列。不带 signal 的 Web 调用共用一个模块 Worker、 +串行请求队列和已缓存的 WebAssembly 模块;带 signal 的调用使用专用 Worker,确保 +取消仅影响当前操作。对大输入仍应设置应用级并发和内存预算。 ## 补丁格式 diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 572ad65..c836113 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -14,7 +14,7 @@ React Native JavaScript React Native Web -> 强类型公开 API - -> 模块 Web Worker + -> 共享或取消任务专用的模块 Web Worker -> Emscripten MEMFS -> 由同一套 bsdiff + bzip2 C 源码编译的 WebAssembly ``` @@ -22,6 +22,11 @@ React Native Web Worker 边界让高开销二进制计算离开 JavaScript / UI 线程,但不会消除算法成本。 调用方仍需设置符合产品场景的输入大小和时间限制。 +未传 `AbortSignal` 的 Web 调用复用一个 Worker 和已缓存的 Emscripten 模块,避免 +重复初始化 Worker 与 WebAssembly。带 signal 的调用使用专用 Worker,取消 signal +只会终止该 Worker。每个 Worker 在自己的队列内串行执行,并在每次操作后删除 +MEMFS 临时文件。 + ## 补丁线格式 补丁以 24 字节头开始: @@ -50,6 +55,28 @@ bsdiff 和 bzip2 源码,从而保持跨平台兼容。 生成的 `web/bsdiffpatch.mjs` 随 npm 包发布,消费者无需安装 Emscripten。 +## 兼容性验证 + +仓库内 golden fixture 证明 Web 实现生成的确定性补丁字节可被 Android 与 iOS +消费。设备运行时测试还会应用 Web golden patch,并验证截断补丁会被拒绝且不会 +留下残缺输出。C patch 核心具有 sanitizer 支持的畸形输入 fuzz 覆盖,非法数据不会 +终止宿主进程。 + +## Web 参考基准 + +`yarn benchmark:web` 使用每 4 KiB 修改一个字节的确定性输入,并逐字节验证还原 +结果。在 Apple M3 Pro、Node 26.5.0 上,仓库记录的 2026-07-19 基准如下: + +| 输入 | Diff | Patch | 补丁字节数 | +| ------ | ----------- | -------- | ---------- | +| 1 MiB | 158.5 ms | 7.7 ms | 110 | +| 10 MiB | 4,243.6 ms | 57.5 ms | 118 | +| 50 MiB | 30,697.5 ms | 285.2 ms | 203 | + +这些数据是可复现的开发基线,不是设备或浏览器性能保证。输入相似度、CPU、浏览器、 +内存压力和工具链版本都会显著影响结果。完整机器可读记录位于 +[`benchmarks/web-wasm.json`](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/benchmarks/web-wasm.json)。 + ## 内存模型 原生操作会把旧文件与目标文件读入进程内存。Web 调用先复制输入再传给 Worker, diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index 3032db5..6598e43 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -31,11 +31,34 @@ yarn test --runInBand yarn test:web yarn test:web:browser yarn test:web:metro +yarn test:package ``` - `test:web` 检查 WebAssembly 往返和补丁 magic。 - `test:web:browser` 在 Chrome 中运行公开 Worker API。 - `test:web:metro` 证明 Metro 选择 `.web` 入口,而不是原生 TurboModule facade。 +- `test:package` 将真实 tarball 安装到干净消费者,验证 browser、ESM、CommonJS、 + TypeScript 与可选 peer 行为。 + +## 原生健壮性与兼容性 + +```sh +FUZZ_RUNS=2000 yarn test:fuzz +scripts/test-rn-android-compatibility.sh 0.73.11 new +scripts/test-rn-android-compatibility.sh 0.74.7 new +scripts/test-rn-android-compatibility.sh 0.86.0 new +``` + +本地 Clang runtime 支持时,fuzz 门禁使用 libFuzzer、AddressSanitizer 与 +UndefinedBehaviorSanitizer;否则运行确定性 sanitizer 语料。兼容 fixture 会使用所选 +React Native artifact 直接编译真实 Android 模块源码,不依赖源码文本断言。 + +可复现的 Web 性能基准命令: + +```sh +yarn benchmark:web +BENCHMARK_OUTPUT=/tmp/web-wasm.json yarn benchmark:web +``` ## 站点与文档 @@ -63,8 +86,10 @@ yarn test:web:browser ## 原生验证 -Android CI 构建两种架构模式,并在模拟器矩阵执行新架构设备级往返测试。iOS CI -使用示例 Gemfile 锁定的 CocoaPods 版本构建并测试旧架构和新架构配置。 +Android CI 构建两种架构模式,使用 React Native 0.73.11、0.74.7 与 0.86.0 +直接编译新架构源码,并在模拟器矩阵执行新架构设备级往返测试。iOS CI 使用示例 +Gemfile 锁定的 CocoaPods 版本构建并测试旧架构和新架构配置。设备测试包含跨平台 +golden patch 和损坏补丁拒绝断言。 本地示例命令见仓库 [CONTRIBUTING.md](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/CONTRIBUTING.md)。 @@ -72,7 +97,7 @@ Android CI 构建两种架构模式,并在模拟器矩阵执行新架构设备 ## 发布检查清单 1. 执行核心、Web 和站点门禁。 -2. 检查 `npm pack --dry-run --ignore-scripts`,确认包含 `web/`。 +2. 运行 `yarn test:package`,并检查 `npm pack --dry-run --ignore-scripts`。 3. 确认公开文档与导出的 TypeScript 声明一致。 4. 确认中英文指南描述同一套公开行为。 5. 运行 `yarn release` 创建版本、tag 和 GitHub Release;该命令不直接发布 npm。 diff --git a/docs/zh-CN/getting-started.md b/docs/zh-CN/getting-started.md index 557cfdd..e7d68e6 100644 --- a/docs/zh-CN/getting-started.md +++ b/docs/zh-CN/getting-started.md @@ -82,8 +82,14 @@ const encoder = new TextEncoder(); const oldData = encoder.encode('version 1'); const newData = encoder.encode('version 2 with web support'); -const patchData = await diffBytes(oldData, newData); -const restoredData = await patchBytes(oldData, patchData); +const controller = new AbortController(); +const options = { + signal: controller.signal, + maxInputBytes: 32 * 1024 * 1024, + maxOutputBytes: 32 * 1024 * 1024, +}; +const patchData = await diffBytes(oldData, newData, options); +const restoredData = await patchBytes(oldData, patchData, options); const matches = restoredData.length === newData.length && @@ -97,6 +103,10 @@ if (!matches) { 输入在传给模块 Worker 前会被复制,因此调用方持有的缓冲区仍可继续使用。每次调用 都会返回新的 `Uint8Array`。 +调用 `controller.abort()` 会让当前操作以 `EABORTED` 拒绝;命中配置的字节上限时 +以 `ERESOURCE` 拒绝。不带 signal 的调用会复用共享 Worker 与 WebAssembly 实例; +带 signal 的调用使用专用 Worker,因此取消一个操作不会中断其他操作。 + ## 使用浏览器文件 ```ts diff --git a/docs/zh-CN/platform-support.md b/docs/zh-CN/platform-support.md index 4ea881f..6632d46 100644 --- a/docs/zh-CN/platform-support.md +++ b/docs/zh-CN/platform-support.md @@ -11,8 +11,9 @@ | 后台执行 | 串行 executor | 串行队列 | 模块 Web Worker | | 补丁格式 | `ENDSLEY/BSDIFF43` | 同左 | 同左 | -示例应用持续验证 React Native 0.73。CI 也通过消费者 fixture 构建验证较新的新架构 -包 API,包括下面说明的 Android 包 API 分界。 +示例应用持续验证 React Native 0.73.2。Android 源码兼容矩阵会直接使用 React +Native 0.73.11、0.74.7 与 0.86.0 编译新架构集成;常规 Android 构建还会编译 +0.73 旧架构。这些是已测试版本,并不承诺所有中间版本或未来版本必然兼容。 ## Android @@ -47,6 +48,7 @@ TurboModule 实例。 - 模块 Web Worker; - `ArrayBuffer` 与 TypedArray; - 使用 `Blob` 输入时的 `Blob.arrayBuffer()`。 +- 使用操作取消功能时的 `AbortController`。 Webpack 与 Vite 能识别标准的 `new Worker(new URL(..., import.meta.url), { type: 'module' })` 模式。Metro Web @@ -55,6 +57,10 @@ Webpack 与 Vite 能识别标准的 Web 入口面向浏览器,不是 Node.js 文件系统适配器;它不会在 Node.js 中提供原生 文件路径 API。 +未传 `AbortSignal` 的调用共用模块 Worker 与已初始化的 WebAssembly 模块;带 +signal 的调用使用专用 Worker,保证取消只影响当前任务。两种路径都会在各自 Worker +内串行执行,但调用方仍应设置应用级内存预算。 + ## 服务端渲染 导入 Web 入口不会创建 Worker。在没有 `Worker` 的环境调用 `diffBytes` 或 diff --git a/docs/zh-CN/recipes.md b/docs/zh-CN/recipes.md index 4ef914f..2f6b192 100644 --- a/docs/zh-CN/recipes.md +++ b/docs/zh-CN/recipes.md @@ -66,11 +66,33 @@ try { - 拒绝超过产品验证上限的输入; - 确认原生端临时输出所需的本地空间; - 防止用户操作触发无限并发; -- 在外围流程适合的位置提供取消能力; +- Web 操作需要取消时传入 `AbortSignal`; - 将超大更新的生成工作放到受控后端基础设施。 -原生调用共用库内部串行队列;不同 Web 调用各自创建 Worker,因此应用应显式限制 -Web 并发。 +原生调用共用库内部串行队列。不带 signal 的 Web 调用复用共享 Worker 与 +WebAssembly 实例;带 signal 的调用使用专用 Worker,因此可以独立终止。应用仍应 +显式限制 Web 总并发和内存。 + +```ts +const controller = new AbortController(); +const options = { + signal: controller.signal, + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 64 * 1024 * 1024, +}; + +try { + const patchData = await diffBytes(oldData, newData, options); + const restoredData = await patchBytes(oldData, patchData, options); +} catch (error) { + if (isPatchError(error) && error.code === 'EABORTED') return; + if (isPatchError(error) && error.code === 'ERESOURCE') { + // 展示产品自己的大小限制说明。 + return; + } + throw error; +} +``` ## 下载 Web 补丁 diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index 8d919bc..b6cf2c2 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -7,6 +7,8 @@ | 找不到原生模块 | 重新构建并安装原生应用 | | `ENOENT`、`EEXIST`、`EINVAL` | 在原生任务开始前检查路径状态 | | `EUNSUPPORTED` | 确认选择了当前平台对应的 API 族 | +| `EABORTED`、`ERESOURCE` | 检查 Web 取消与配置的字节上限 | +| `EDIFF`、`EPATCH` | 检查原生 I/O 与补丁完整性 | | Worker 或 `EWEBASSEMBLY` 失败 | 检查 Web 资源、CSP 和补丁 magic | | 内存占用过高 | 设置输入大小和并发限制 | @@ -42,6 +44,12 @@ 路径版 `diff` 和 `patch` 仅原生可用。React Native Web 应使用 `diffBytes` 和 `patchBytes`。如果错误指出需要 Web Worker,请在浏览器客户端而不是 SSR 中调用。 +## `EABORTED` 或 `ERESOURCE` + +`EABORTED` 表示传给 Web 操作的 `AbortSignal` 在开始前或专用 Worker 运行时被取消。 +`ERESOURCE` 表示输入、生成补丁或补丁声明的还原输出超过配置的字节上限。二者是预期 +控制流错误,并不表示 WebAssembly 损坏。 + ## Worker 加载失败 确认打包器输出了模块 Worker 资源,并且服务器将 `.mjs` 作为 JavaScript 提供。 @@ -50,18 +58,22 @@ 在浏览器网络面板中确认 `worker.mjs`、`operations.mjs` 和 `bsdiffpatch.mjs` 返回成功状态,而不是应用 HTML fallback。 -## `EWEBASSEMBLY` 或补丁损坏 +## `EPATCH`、`EWEBASSEMBLY` 或补丁损坏 检查补丁前 16 字节。受支持补丁以 `ENDSLEY/BSDIFF43` 开头。截断补丁、 `BSDIFF40` 补丁或其他二进制数据都会被拒绝。 +原生端损坏补丁使用 `EPATCH`,补丁生成失败使用 `EDIFF`。Web 校验与 C 核心失败使用 +`EWEBASSEMBLY`,除非资源上限提供了更具体的 `ERESOURCE`。原生调用失败时会删除该 +操作拥有的残留输出。 + ## 内存占用过高 算法和适配器处理完整内存缓冲区。调用前添加大小限制,不要接受任意大的不可信文件。 Web 虽在主线程外执行,仍会消耗当前标签页内存。 -不同 Web 操作会创建不同 Worker。对重复用户操作进行防抖;大任务可能重叠时使用 -应用级队列。 +不带 signal 的 Web 操作在共享 Worker 中排队;带 signal 的操作使用专用 Worker, +以实现隔离取消。对重复用户操作进行防抖;大任务可能重叠时设置应用级预算。 ## 还原结果不一致 diff --git a/example/src/App.tsx b/example/src/App.tsx index bd379b5..d8ecd8a 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,11 +4,21 @@ import { StyleSheet, View, Text } from 'react-native'; import { diff, patch } from 'react-native-bs-diff-patch'; import * as FS from 'react-native-fs'; +import crossPlatformFixture from '../../fixtures/cross-platform.json'; + export default function App() { const newFile = FS.DocumentDirectoryPath + '/test1.txt'; const oldFile = FS.DocumentDirectoryPath + '/test.txt'; const patchFile = FS.DocumentDirectoryPath + '/patch.txt'; const newFile1 = FS.DocumentDirectoryPath + '/test2.txt'; + const goldenOldFile = FS.DocumentDirectoryPath + '/golden-old.bin'; + const goldenNewFile = FS.DocumentDirectoryPath + '/golden-new.bin'; + const goldenPatchFile = FS.DocumentDirectoryPath + '/golden.patch'; + const goldenOutputFile = FS.DocumentDirectoryPath + '/golden-output.bin'; + const generatedGoldenPatchFile = + FS.DocumentDirectoryPath + '/golden-generated.patch'; + const corruptPatchFile = FS.DocumentDirectoryPath + '/corrupt.patch'; + const corruptOutputFile = FS.DocumentDirectoryPath + '/corrupt-output.bin'; const [textLength, setTextLength] = React.useState(); const [patchFileUri, setPatchFileUri] = React.useState(); @@ -31,6 +41,20 @@ export default function App() { await FS.unlink(newFile1); } + for (const file of [ + goldenOldFile, + goldenNewFile, + goldenPatchFile, + goldenOutputFile, + generatedGoldenPatchFile, + corruptPatchFile, + corruptOutputFile, + ]) { + if (await FS.exists(file)) { + await FS.unlink(file); + } + } + const diffResult = await diff(oldFile, newFile, patchFile); const patchFileInfo = await FS.stat(patchFile); const patchResult = await patch(oldFile, newFile1, patchFile); @@ -46,6 +70,63 @@ export default function App() { ); } + await FS.writeFile( + goldenOldFile, + crossPlatformFixture.oldBase64, + 'base64' + ); + await FS.writeFile( + goldenNewFile, + crossPlatformFixture.newBase64, + 'base64' + ); + await FS.writeFile( + goldenPatchFile, + crossPlatformFixture.patchBase64, + 'base64' + ); + await diff(goldenOldFile, goldenNewFile, generatedGoldenPatchFile); + const generatedGoldenPatch = await FS.readFile( + generatedGoldenPatchFile, + 'base64' + ); + if (generatedGoldenPatch !== crossPlatformFixture.patchBase64) { + throw new Error('native diff did not match the cross-platform patch'); + } + + await patch(goldenOldFile, goldenOutputFile, goldenPatchFile); + const restoredGoldenFile = await FS.readFile( + goldenOutputFile, + 'base64' + ); + if (restoredGoldenFile !== crossPlatformFixture.newBase64) { + throw new Error( + 'native patch did not restore the cross-platform fixture' + ); + } + + await FS.writeFile( + corruptPatchFile, + 'bm90IGEgYnNkaWZmIHBhdGNo', + 'base64' + ); + let corruptPatchErrorCode: string | undefined; + try { + await patch(goldenOldFile, corruptOutputFile, corruptPatchFile); + } catch (error) { + corruptPatchErrorCode = (error as { code?: string }).code; + } + if (corruptPatchErrorCode !== 'EPATCH') { + throw new Error( + `corrupt patch should reject with EPATCH, got ${String( + corruptPatchErrorCode + )}` + ); + } + if (await FS.exists(corruptOutputFile)) { + throw new Error('corrupt patch left a partial output file'); + } + if (!cancelled) { setPatchFileUri(patchFileInfo.path); setTextLength(patchedContent.length); @@ -75,7 +156,19 @@ export default function App() { } }); }; - }, [newFile, newFile1, oldFile, patchFile]); + }, [ + corruptOutputFile, + corruptPatchFile, + generatedGoldenPatchFile, + goldenNewFile, + goldenOldFile, + goldenOutputFile, + goldenPatchFile, + newFile, + newFile1, + oldFile, + patchFile, + ]); return ( diff --git a/fixtures/cross-platform.json b/fixtures/cross-platform.json new file mode 100644 index 0000000..4ab403f --- /dev/null +++ b/fixtures/cross-platform.json @@ -0,0 +1,6 @@ +{ + "format": "ENDSLEY/BSDIFF43", + "oldBase64": "Y3Jvc3MtcGxhdGZvcm0gYmFzZWxpbmUgdjEK", + "newBase64": "Y3Jvc3MtcGxhdGZvcm0gYmFzZWxpbmUgdjIgd2l0aCBzaGFyZWQgcGF0Y2gK", + "patchBase64": "RU5EU0xFWS9CU0RJRkY0My0AAAAAAAAAQlpoOTFBWSZTWU/af/UAAAj5gGgQBCRAABAALmBcgCAAIiZpAAYhTCaaA0xFmBIiUy2lBpcXTr9bweGczhPi7kinChIJ+0/+oA==" +} diff --git a/ios/BsDiffPatch.mm b/ios/BsDiffPatch.mm index 3649814..bcc29a6 100644 --- a/ios/BsDiffPatch.mm +++ b/ios/BsDiffPatch.mm @@ -63,8 +63,13 @@ - (dispatch_queue_t)methodQueue const char *newFileCString = [newFile UTF8String]; const char *patchFileCString = [patchFile UTF8String]; - NSNumber *result = @(bsdiffpatch::patchFile(oldFileCString, newFileCString, patchFileCString)); - resolve(result); + int result = bsdiffpatch::patchFile(oldFileCString, newFileCString, patchFileCString); + if (result != 0) { + NSString *message = [NSString stringWithFormat:@"patch failed with native result %d", result]; + reject(@"EPATCH", message, nil); + return; + } + resolve(@(result)); } RCT_EXPORT_METHOD(diff:(NSString*) oldFile @@ -105,8 +110,13 @@ - (dispatch_queue_t)methodQueue const char *newFileCString = [newFile UTF8String]; const char *patchFileCString = [patchFile UTF8String]; - NSNumber *result = @(bsdiffpatch::diffFile(oldFileCString, newFileCString, patchFileCString)); - resolve(result); + int result = bsdiffpatch::diffFile(oldFileCString, newFileCString, patchFileCString); + if (result != 0) { + NSString *message = [NSString stringWithFormat:@"diff failed with native result %d", result]; + reject(@"EDIFF", message, nil); + return; + } + resolve(@(result)); } #ifdef RCT_NEW_ARCH_ENABLED diff --git a/package.json b/package.json index 1827061..b60f322 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,17 @@ "types": "lib/typescript/src/index.d.ts", "react-native": "src/index", "source": "src/index", + "exports": { + ".": { + "types": "./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" + }, + "./package.json": "./package.json" + }, "files": [ "src", "lib", @@ -15,7 +26,6 @@ "ios", "cpp", "web", - "scripts/build-web-wasm.sh", "*.podspec", "!ios/build", "!android/build", @@ -29,6 +39,7 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", + "!cpp/fuzz", "!**/.*" ], "scripts": { @@ -37,6 +48,9 @@ "test:web": "node scripts/test-web.mjs", "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:fuzz": "sh scripts/test-native-fuzz.sh", + "benchmark:web": "node scripts/benchmark-web.mjs", "site:build": "node scripts/build-site.mjs", "site:test": "node scripts/test-site.mjs", "site:test:browser": "node scripts/test-site-browser.mjs", @@ -44,7 +58,7 @@ "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", + "prepare": "bob build && node scripts/prepare-package.mjs", "build:web": "bash scripts/build-web-wasm.sh", "release": "release-it" }, @@ -64,7 +78,7 @@ "bugs": { "url": "https://github.com/JimmyDaddy/react-native-bs-diff-patch/issues" }, - "homepage": "https://github.com/JimmyDaddy/react-native-bs-diff-patch#readme", + "homepage": "https://bs-dff-patch.corerobin.com", "publishConfig": { "registry": "https://registry.npmjs.org/" }, @@ -99,6 +113,14 @@ "react": "*", "react-native": "*" }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + }, "workspaces": [ "example" ], @@ -131,7 +153,8 @@ }, "plugins": { "@release-it/conventional-changelog": { - "preset": "angular" + "preset": "angular", + "infile": "CHANGELOG.md" } } }, diff --git a/scripts/benchmark-web.mjs b/scripts/benchmark-web.mjs new file mode 100644 index 0000000..d08bdee --- /dev/null +++ b/scripts/benchmark-web.mjs @@ -0,0 +1,80 @@ +import { cpus } from 'node:os'; +import { writeFile } from 'node:fs/promises'; +import { performance } from 'node:perf_hooks'; + +import { runOperation } from '../web/operations.mjs'; + +const mebibyte = 1024 * 1024; +const sizes = (process.env.BENCHMARK_SIZES_MIB || '1,10,50') + .split(',') + .map((value) => Number(value.trim())); + +if ( + sizes.some( + (value) => !Number.isSafeInteger(value) || value <= 0 || value > 512 + ) +) { + throw new Error('BENCHMARK_SIZES_MIB must contain integers from 1 to 512'); +} + +function createInputs(size) { + const oldData = new Uint8Array(size); + for (let index = 0; index < oldData.length; index += 1) { + oldData[index] = (index * 31 + (index >>> 8)) & 0xff; + } + const newData = oldData.slice(); + for (let index = 0; index < newData.length; index += 4096) { + newData[index] ^= 0x5a; + } + return { oldData, newData }; +} + +const warmup = createInputs(64 * 1024); +const initializationStartedAt = performance.now(); +await runOperation('diff', warmup.oldData, warmup.newData); +const initializationMs = performance.now() - initializationStartedAt; +const results = []; + +for (const sizeMiB of sizes) { + const { oldData, newData } = createInputs(sizeMiB * mebibyte); + const diffStartedAt = performance.now(); + const patchData = await runOperation('diff', oldData, newData); + const diffMs = performance.now() - diffStartedAt; + const patchStartedAt = performance.now(); + const restoredData = await runOperation('patch', oldData, patchData); + const patchMs = performance.now() - patchStartedAt; + + if ( + restoredData.byteLength !== newData.byteLength || + !restoredData.every((value, index) => value === newData[index]) + ) { + throw new Error(`Benchmark round trip failed for ${sizeMiB} MiB`); + } + + results.push({ + sizeMiB, + diffMs: Number(diffMs.toFixed(1)), + patchMs: Number(patchMs.toFixed(1)), + patchBytes: patchData.byteLength, + }); +} + +const report = { + generatedAt: new Date().toISOString(), + runtime: { + cpu: cpus()[0]?.model || 'unknown', + node: process.version, + platform: `${process.platform}-${process.arch}`, + }, + workload: { + description: 'Deterministic buffers with one changed byte per 4 KiB', + initializationMs: Number(initializationMs.toFixed(1)), + }, + results, +}; +const serializedReport = `${JSON.stringify(report, null, 2)}\n`; + +if (process.env.BENCHMARK_OUTPUT) { + await writeFile(process.env.BENCHMARK_OUTPUT, serializedReport); +} +process.stdout.write(serializedReport); diff --git a/scripts/prepare-package.mjs b/scripts/prepare-package.mjs new file mode 100644 index 0000000..f40c168 --- /dev/null +++ b/scripts/prepare-package.mjs @@ -0,0 +1,27 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryDirectory = path.resolve(scriptDirectory, '..'); +const moduleDirectory = path.join(repositoryDirectory, 'lib/module'); + +await mkdir(moduleDirectory, { recursive: true }); +await writeFile( + path.join(moduleDirectory, 'package.json'), + `${JSON.stringify({ type: 'module' }, null, 2)}\n` +); + +for (const filename of await readdir(moduleDirectory)) { + if (!filename.endsWith('.js')) { + continue; + } + const filePath = path.join(moduleDirectory, filename); + const source = await readFile(filePath, 'utf8'); + const nodeCompatibleSource = source.replace( + /(from\s+['"])(\.\.?\/[^'"]+)(['"])/g, + (match, prefix, specifier, suffix) => + path.extname(specifier) ? match : `${prefix}${specifier}.js${suffix}` + ); + await writeFile(filePath, nodeCompatibleSource); +} diff --git a/scripts/test-native-fuzz.sh b/scripts/test-native-fuzz.sh new file mode 100755 index 0000000..1414022 --- /dev/null +++ b/scripts/test-native-fuzz.sh @@ -0,0 +1,44 @@ +#!/bin/sh + +set -eu + +repository_directory=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +temporary_directory=$(mktemp -d) +fuzz_runs="${FUZZ_RUNS:-5000}" +compiler="${CC:-clang}" + +cleanup() { + rm -rf "$temporary_directory" +} +trap cleanup EXIT INT TERM + +if "$compiler" \ + -std=c11 \ + -g \ + -O1 \ + -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + -I"$repository_directory/cpp" \ + "$repository_directory/cpp/bspatch.c" \ + "$repository_directory/cpp/fuzz/bspatch_fuzzer.c" \ + -lbz2 \ + -o "$temporary_directory/bspatch-fuzzer" 2>/dev/null; then + "$temporary_directory/bspatch-fuzzer" \ + -runs="$fuzz_runs" \ + -max_len=512 \ + -print_final_stats=1 +else + "$compiler" \ + -std=c11 \ + -g \ + -O1 \ + -fno-omit-frame-pointer \ + -DBSDIFFPATCH_STANDALONE_FUZZ=1 \ + -fsanitize=address,undefined \ + -I"$repository_directory/cpp" \ + "$repository_directory/cpp/bspatch.c" \ + "$repository_directory/cpp/fuzz/bspatch_fuzzer.c" \ + -lbz2 \ + -o "$temporary_directory/bspatch-fuzzer" + "$temporary_directory/bspatch-fuzzer" "$fuzz_runs" +fi diff --git a/scripts/test-package-consumers.mjs b/scripts/test-package-consumers.mjs new file mode 100644 index 0000000..17449dd --- /dev/null +++ b/scripts/test-package-consumers.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryDirectory = path.resolve(scriptDirectory, '..'); +const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'react-native-bs-diff-patch-consumer-') +); +const consumerDirectory = path.join(temporaryDirectory, 'consumer'); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || repositoryDirectory, + encoding: 'utf8', + env: { ...process.env, ...options.env }, + }); + + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed:\n${result.stdout || ''}${ + result.stderr || '' + }` + ); + } + + return result.stdout.trim(); +} + +async function pathExists(candidate) { + try { + await access(candidate); + return true; + } catch { + return false; + } +} + +try { + const packOutput = JSON.parse( + run('npm', [ + 'pack', + '--ignore-scripts', + '--json', + '--pack-destination', + temporaryDirectory, + ]) + ); + const tarballPath = path.join(temporaryDirectory, packOutput[0].filename); + + await mkdir(consumerDirectory, { recursive: true }); + await writeFile( + path.join(consumerDirectory, 'package.json'), + `${JSON.stringify( + { name: 'package-consumer-smoke', private: true }, + null, + 2 + )}\n` + ); + run( + 'npm', + [ + 'install', + tarballPath, + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + ], + { cwd: consumerDirectory } + ); + + assert.equal( + await pathExists(path.join(consumerDirectory, 'node_modules/react')), + false, + 'A browser-only install must not auto-install the optional React peer' + ); + assert.equal( + await pathExists(path.join(consumerDirectory, 'node_modules/react-native')), + false, + 'A browser-only install must not auto-install the optional React Native peer' + ); + + const fakeReactNativeDirectory = path.join( + consumerDirectory, + 'node_modules/react-native' + ); + await mkdir(fakeReactNativeDirectory, { recursive: true }); + await writeFile( + path.join(fakeReactNativeDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'react-native', + version: '0.0.0-test', + exports: { import: './index.mjs', require: './index.cjs' }, + }, + null, + 2 + )}\n` + ); + const fakeRegistry = + 'const moduleValue = { diff: async () => 0, patch: async () => 0 };\n'; + await writeFile( + path.join(fakeReactNativeDirectory, 'index.mjs'), + `${fakeRegistry}export const TurboModuleRegistry = { getEnforcing: () => moduleValue };\n` + ); + await writeFile( + path.join(fakeReactNativeDirectory, 'index.cjs'), + `${fakeRegistry}exports.TurboModuleRegistry = { getEnforcing: () => moduleValue };\n` + ); + + const installedPackageDirectory = path.join( + consumerDirectory, + 'node_modules/react-native-bs-diff-patch' + ); + const installedManifest = JSON.parse( + await readFile(path.join(installedPackageDirectory, 'package.json'), 'utf8') + ); + assert.equal(installedManifest.exports['.'].browser, './web/index.mjs'); + assert.equal( + installedManifest.exports['.']['react-native'], + './src/index.ts' + ); + + await writeFile( + path.join(consumerDirectory, 'resolve.cjs'), + "console.log(require.resolve('react-native-bs-diff-patch'));\n" + ); + assert.match( + run('node', ['resolve.cjs'], { cwd: consumerDirectory }), + /lib\/commonjs\/index\.js$/ + ); + + await writeFile( + path.join(consumerDirectory, 'resolve.mjs'), + "console.log(import.meta.resolve('react-native-bs-diff-patch'));\n" + ); + assert.match( + run('node', ['resolve.mjs'], { cwd: consumerDirectory }), + /lib\/module\/index\.js$/ + ); + + await writeFile( + path.join(consumerDirectory, 'load.mjs'), + [ + "import { diff, diffBytes } from 'react-native-bs-diff-patch';", + "if ((await diff('old', 'new', 'patch')) !== 0) throw new Error('ESM diff failed');", + 'const error = await diffBytes(new Uint8Array(), new Uint8Array()).catch((value) => value);', + "if (!error || error.code !== 'EUNSUPPORTED') throw new Error('ESM facade failed');", + ].join('\n') + ); + run('node', ['load.mjs'], { cwd: consumerDirectory }); + + await writeFile( + path.join(consumerDirectory, 'load.cjs'), + [ + "const { diff, diffBytes } = require('react-native-bs-diff-patch');", + 'void (async () => {', + " if ((await diff('old', 'new', 'patch')) !== 0) throw new Error('CJS diff failed');", + ' const error = await diffBytes(new Uint8Array(), new Uint8Array()).catch((value) => value);', + " if (!error || error.code !== 'EUNSUPPORTED') throw new Error('CJS facade failed');", + '})().catch((error) => { console.error(error); process.exitCode = 1; });', + ].join('\n') + ); + run('node', ['load.cjs'], { cwd: consumerDirectory }); + + await writeFile( + path.join(consumerDirectory, 'browser.mjs'), + [ + "import { diffBytes, patch } from 'react-native-bs-diff-patch';", + "if (typeof diffBytes !== 'function') throw new Error('Missing Web binary API');", + "const error = await patch('old', 'new', 'patch').catch((value) => value);", + "if (!error || error.code !== 'EUNSUPPORTED') throw new Error('Wrong Web path API');", + ].join('\n') + ); + run('node', ['--conditions=browser', 'browser.mjs'], { + cwd: consumerDirectory, + }); + + await writeFile( + path.join(consumerDirectory, 'consumer.ts'), + [ + "import { diffBytes, type BinaryInput } from 'react-native-bs-diff-patch';", + 'const input: BinaryInput = new Uint8Array([1, 2, 3]);', + 'void diffBytes(input, input);', + ].join('\n') + ); + run( + process.execPath, + [ + path.join(repositoryDirectory, 'node_modules/typescript/bin/tsc'), + '--noEmit', + '--strict', + '--target', + 'ES2022', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--customConditions', + 'browser', + '--lib', + 'ES2022,DOM', + 'consumer.ts', + ], + { cwd: consumerDirectory } + ); + + console.log( + 'Packed consumer install, optional peers, conditional exports, and types passed' + ); +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/test-rn-android-compatibility.sh b/scripts/test-rn-android-compatibility.sh new file mode 100755 index 0000000..9c0e5bb --- /dev/null +++ b/scripts/test-rn-android-compatibility.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -eu + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: $0 [old|new]" >&2 + exit 2 +fi + +react_native_version="$1" +architecture="${2:-new}" +repository_directory=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +react_native_minor=$(printf '%s\n' "$react_native_version" | cut -d. -f2) + +if [ "$react_native_minor" -ge 86 ]; then + kotlin_version=2.1.20 +else + kotlin_version=1.9.22 +fi + +"$repository_directory/example/android/gradlew" \ + -p "$repository_directory/compatibility/android-api" \ + --no-daemon \ + --stacktrace \ + -PreactNativeVersion="$react_native_version" \ + -PkotlinVersion="$kotlin_version" \ + -Parchitecture="$architecture" \ + clean compileReleaseKotlin diff --git a/scripts/test-site-browser.mjs b/scripts/test-site-browser.mjs index 56e02fd..0344d9e 100644 --- a/scripts/test-site-browser.mjs +++ b/scripts/test-site-browser.mjs @@ -89,10 +89,12 @@ try { heading: document.querySelector('h1')?.textContent, patchSize: document.querySelector('#patch-size')?.textContent, status: document.querySelector('#playground-status')?.textContent?.trim(), + evidenceRows: document.querySelectorAll('.benchmark-table tbody tr').length, })); assert.match(result.heading || '', /Binary deltas/); assert.notEqual(result.patchSize, '—'); assert.match(result.status || '', /verified byte-for-byte/); + assert.equal(result.evidenceRows, 3); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); await page.reload({ waitUntil: 'networkidle0' }); diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs index d5688d5..3db4514 100644 --- a/scripts/test-site.mjs +++ b/scripts/test-site.mjs @@ -155,6 +155,11 @@ const homepage = await readFile( ); assert.match(homepage, /id="playground"/); assert.match(homepage, /id="generate-patch"/); +assert.match(homepage, /id="evidence"/); +assert.match(homepage, /RN 0\.73\.11/); +assert.match(homepage, /RN 0\.86\.0/); +assert.match(homepage, /111 KiB packed/); +assert.match(homepage, /30,697\.5 ms/); assert.match(homepage, /assets\/playground\.js/); console.log('Site structure and local links passed'); diff --git a/scripts/test-web-browser.mjs b/scripts/test-web-browser.mjs index d44462b..38f6e15 100644 --- a/scripts/test-web-browser.mjs +++ b/scripts/test-web-browser.mjs @@ -81,11 +81,16 @@ try { const result = await page.evaluate(() => window.__bsdiffWebTestResult); assert.deepEqual(result, { + activeAbortErrorCode: 'EABORTED', + abortErrorCode: 'EABORTED', inputsPreserved: true, + inputLimitErrorCode: 'ERESOURCE', invalidInputErrorCode: 'EINVAL', + outputLimitErrorCode: 'ERESOURCE', patchLength: result.patchLength, pathApiErrorCode: 'EUNSUPPORTED', restoredMatches: true, + sharedSurvivedAbort: true, }); assert.ok(result.patchLength > 24); console.log('Browser Web Worker diff/patch round trip passed'); diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs index d8053e9..9def537 100644 --- a/scripts/test-web.mjs +++ b/scripts/test-web.mjs @@ -1,7 +1,18 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { runOperation } from '../web/operations.mjs'; +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + await readFile( + path.join(scriptDirectory, '../fixtures/cross-platform.json'), + 'utf8' + ) +); + const encoder = new TextEncoder(); const oldData = encoder.encode('hello from the old file\n'.repeat(128)); const newData = encoder.encode( @@ -23,10 +34,41 @@ assert.deepEqual( 'patch should reconstruct the new bytes' ); +const goldenOldData = new Uint8Array(Buffer.from(fixture.oldBase64, 'base64')); +const goldenNewData = new Uint8Array(Buffer.from(fixture.newBase64, 'base64')); +const goldenPatchData = new Uint8Array( + Buffer.from(fixture.patchBase64, 'base64') +); + +await assert.rejects( + runOperation('patch', goldenOldData, goldenPatchData, { + maxOutputBytes: goldenNewData.byteLength - 1, + }), + (error) => error && error.code === 'ERESOURCE', + 'declared patch outputs over the configured limit should reject before patching' +); + +await assert.rejects( + runOperation('patch', goldenOldData, goldenPatchData.subarray(0, 25)), + (error) => error && error.code === 'EWEBASSEMBLY', + 'truncated compressed patch data should fail without exiting the runtime' +); + await assert.rejects( runOperation('patch', oldData, new Uint8Array([1, 2, 3])), (error) => error && error.code === 'EWEBASSEMBLY', 'corrupt patches should reject with a WebAssembly error' ); +assert.deepEqual( + await runOperation('diff', goldenOldData, goldenNewData), + goldenPatchData, + 'Web diff output should remain byte-compatible with the cross-platform fixture' +); +assert.deepEqual( + await runOperation('patch', goldenOldData, goldenPatchData), + goldenNewData, + 'Web should apply the patch shared with Android and iOS' +); + console.log('WebAssembly diff/patch round trip passed'); diff --git a/scripts/web-test.html b/scripts/web-test.html index 55a11ce..e07ba88 100644 --- a/scripts/web-test.html +++ b/scripts/web-test.html @@ -45,21 +45,78 @@

BsDiffPatch Web Test

invalidInputErrorCode = error.code; } + let inputLimitErrorCode; + try { + await diffBytes(oldData, newData, { maxInputBytes: 1 }); + } catch (error) { + inputLimitErrorCode = error.code; + } + + let outputLimitErrorCode; + try { + await patchBytes(oldData, patchData, { + maxOutputBytes: newData.byteLength - 1, + }); + } catch (error) { + outputLimitErrorCode = error.code; + } + + const abortController = new AbortController(); + abortController.abort(); + let abortErrorCode; + try { + await diffBytes(oldData, newData, { + signal: abortController.signal, + }); + } catch (error) { + 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); + let activeAbortErrorCode; + try { + await activeOperation; + } catch (error) { + activeAbortErrorCode = error.code; + } + + const afterAbortData = await patchBytes(oldData, patchData); + const sharedSurvivedAbort = + afterAbortData.length === newData.length && + afterAbortData.every((value, index) => value === newData[index]); + if ( !restoredMatches || !inputsPreserved || + activeAbortErrorCode !== 'EABORTED' || + abortErrorCode !== 'EABORTED' || + inputLimitErrorCode !== 'ERESOURCE' || invalidInputErrorCode !== 'EINVAL' || - pathApiErrorCode !== 'EUNSUPPORTED' + outputLimitErrorCode !== 'ERESOURCE' || + pathApiErrorCode !== 'EUNSUPPORTED' || + !sharedSurvivedAbort ) { throw new Error('browser assertions failed'); } window.__bsdiffWebTestResult = { + activeAbortErrorCode, + abortErrorCode, inputsPreserved, + inputLimitErrorCode, invalidInputErrorCode, + outputLimitErrorCode, patchLength: patchData.length, pathApiErrorCode, restoredMatches, + sharedSurvivedAbort, }; document.body.dataset.status = 'passed'; status.textContent = 'Passed'; diff --git a/site/assets/site.css b/site/assets/site.css index bbbbf07..0808a0b 100644 --- a/site/assets/site.css +++ b/site/assets/site.css @@ -695,6 +695,7 @@ code { .architecture h2, .proof-copy h2, +.evidence-heading h2, .docs-cta h2, .docs-hero h1 { margin: 0; @@ -813,6 +814,132 @@ code { line-height: 1.65; } +.evidence-section { + border-bottom: 1px solid var(--line); + background: linear-gradient(145deg, var(--panel-black), var(--panel)); +} + +.evidence-heading { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + align-items: end; + gap: 72px; + padding: 68px 48px 46px; + border-bottom: 1px solid var(--line); +} + +.evidence-heading h2 { + max-width: 720px; +} + +.evidence-heading > p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.7; +} + +.compatibility-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-bottom: 1px solid var(--line); +} + +.compatibility-strip article { + min-width: 0; + padding: 30px; + border-right: 1px solid var(--line); +} + +.compatibility-strip article:last-child { + border-right: 0; +} + +.compatibility-strip span { + display: block; + margin-bottom: 20px; + color: var(--cyan); + font: 700 11px/1 var(--mono); + text-transform: uppercase; +} + +.compatibility-strip strong { + font: 750 15px/1.3 var(--mono); +} + +.compatibility-strip p { + margin: 10px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.compatibility-strip .package-footprint span { + color: var(--lime); +} + +.benchmark-panel { + display: grid; + grid-template-columns: minmax(280px, 0.75fr) minmax(560px, 1.25fr); +} + +.benchmark-copy { + padding: 50px 48px; + border-right: 1px solid var(--line); +} + +.benchmark-copy h3 { + margin: 0; + font: 750 24px/1.2 var(--mono); +} + +.benchmark-copy > p:not(.section-kicker) { + margin: 18px 0 24px; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.benchmark-table-wrap { + align-self: center; + overflow-x: auto; + padding: 36px 40px; +} + +.benchmark-table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + font: 700 12px/1.4 var(--mono); +} + +.benchmark-table th, +.benchmark-table td { + padding: 18px 14px; + text-align: right; + border-bottom: 1px solid var(--line); +} + +.benchmark-table th:first-child, +.benchmark-table td:first-child { + text-align: left; +} + +.benchmark-table thead th { + color: var(--muted-dark); + font-size: 10px; + text-transform: uppercase; +} + +.benchmark-table tbody th { + color: var(--lime); +} + +.benchmark-table tbody tr:last-child th, +.benchmark-table tbody tr:last-child td { + border-bottom: 0; +} + .docs-cta { display: grid; grid-template-columns: 1.1fr 0.85fr auto; @@ -1170,6 +1297,27 @@ code { grid-template-columns: 1fr 1fr; } + .compatibility-strip { + grid-template-columns: repeat(2, 1fr); + } + + .compatibility-strip article:nth-child(2) { + border-right: 0; + } + + .compatibility-strip article:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + + .benchmark-panel { + grid-template-columns: 1fr; + } + + .benchmark-copy { + border-right: 0; + border-bottom: 1px solid var(--line); + } + .cta-actions { grid-column: 1 / -1; grid-template-columns: repeat(2, max-content); @@ -1278,6 +1426,7 @@ code { } .proof-section, + .evidence-heading, .docs-cta { grid-template-columns: 1fr; } @@ -1292,6 +1441,19 @@ code { padding: 54px 28px; } + .evidence-heading { + gap: 24px; + padding: 54px 28px 40px; + } + + .benchmark-copy { + padding: 42px 28px; + } + + .benchmark-table-wrap { + padding: 26px 22px; + } + .cta-actions { grid-column: auto; } diff --git a/site/index.html b/site/index.html index 952eca6..031be57 100644 --- a/site/index.html +++ b/site/index.html @@ -34,6 +34,7 @@