diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index fb98c79..1b27ecc 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,27 +1,20 @@ name: Setup -description: Setup Node.js and install dependencies +description: Set up the repository Node.js toolchain and install dependencies runs: using: composite steps: - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version-file: .nvmrc + cache: yarn + cache-dependency-path: yarn.lock - - name: Cache dependencies - id: yarn-cache - uses: actions/cache@v3 - with: - path: | - **/node_modules - .yarn/install-state.gz - key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} - restore-keys: | - ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} - ${{ runner.os }}-yarn- + - name: Enable Corepack + run: corepack enable + shell: bash - name: Install dependencies - if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install --immutable shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e231f86..cda5f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,321 +1,394 @@ name: CI + on: pull_request: types: [opened, synchronize, reopened] + schedule: + - cron: '17 3 * * 1' workflow_dispatch: -jobs: +permissions: + contents: read - install-dep: - runs-on: macos-latest - name: Install dependencies - steps: - - name: Checkout the code - uses: actions/checkout@v4 - - - name: Verify Dev Changed files - uses: tj-actions/changed-files@v46 - id: verify-dev-changed-files - with: - files: | - !*.md - !*.MD - !*.yml - - - uses: actions/cache@v3 - name: Cache node_modules - id: cache-node-modules - if: steps.verify-dev-changed-files.outputs.any_changed == 'true' - with: - path: | - node_modules - example/node_modules - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - - - name: Set up Ruby - if: steps.verify-dev-changed-files.outputs.any_changed == 'true' - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.7 - bundler-cache: true - - - name: Setup node - if: steps.verify-dev-changed-files.outputs.any_changed == 'true' - uses: actions/setup-node@v3 - with: - node-version: '18' - - - - name: Install yarn - if: steps.verify-dev-changed-files.outputs.any_changed == 'true' - run: npm install -g yarn@1.22.10 - - - name: Install dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' && steps.verify-dev-changed-files.outputs.any_changed == 'true' - run: | - yarn +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true - android-build: - runs-on: macos-latest - name: Android Build - needs: install-dep +jobs: + changes: + name: Classify changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + android: ${{ steps.changes.outputs.android }} + ios: ${{ steps.changes.outputs.ios }} + quality: ${{ steps.changes.outputs.quality }} + site: ${{ steps.changes.outputs.site }} + web: ${{ steps.changes.outputs.web }} steps: - - name: Checkout the code - uses: actions/checkout@v4 - - - name: Verify Android Changed files - uses: tj-actions/changed-files@v46 - id: verify-android-changed-files - with: - files: | - android/** - src/** - assets/** - package.json - !example/ios/** - example/e2e/** - - - uses: actions/cache@v3 - name: Cache node_modules - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - id: cache-node-modules - with: - path: | - node_modules - example/node_modules - fail-on-cache-miss: true - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - - - uses: actions/cache@v3 - id: cache-gradle - name: Cache Gradle dependencies - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/src/**/*.kt') }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - ruby-version: 2.7 - bundler-cache: true - - - name: Setup node - uses: actions/setup-node@v3 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - node-version: '18' - - - name: Set up JDK - uses: actions/setup-java@v3 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - distribution: 'zulu' - java-version: 17 - - - name: Install Gradle dependencies - if: steps.cache-gradle.outputs.cache-hit != 'true' && steps.verify-android-changed-files.outputs.any_changed == 'true' - run: | - cd example/android - ./gradlew build --stacktrace - - - name: Run unit tests - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - run: | - cd example/android - ./gradlew test --stacktrace - - - name: Build APK - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - run: | - npm run prepare - cd example/android - ./gradlew assembleRelease - mv app/build/outputs/apk/release/app-release.apk app-release-${{ github.sha }}.apk - - - name: Upload APK - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - uses: actions/upload-artifact@v3 - with: - name: app-release-${{ github.sha }}.apk - path: ${{ github.workspace }}/example/android/app-release-${{ github.sha }}.apk + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + + - name: Classify changed files + id: changes + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + android=false + ios=false + quality=false + site=false + web=false + + if [[ "$EVENT_NAME" != "pull_request" ]]; then + android=true + ios=true + quality=true + site=true + web=true + else + while IFS= read -r file; do + case "$file" in + .github/workflows/ci.yml|.github/actions/**|package.json|yarn.lock|.yarnrc.yml|.yarn/releases/**|.nvmrc) + android=true + ios=true + quality=true + site=true + web=true + ;; + src/**|cpp/**|assets/**) + android=true + ios=true + quality=true + web=true + ;; + android/**|example/android/**|scripts/run-android-runtime-test.sh) + android=true + quality=true + ;; + ios/**|example/ios/**|*.podspec) + ios=true + quality=true + ;; + example/src/**|example/e2e/**) + android=true + ios=true + quality=true + ;; + web/**|scripts/build-web-wasm.sh|scripts/test-web*.mjs|scripts/web-*) + web=true + quality=true + site=true + ;; + site/**|docs/**|README*.md|CONTRIBUTING.md|scripts/build-site.mjs|scripts/test-site*.mjs|.github/workflows/pages.yml) + site=true + ;; + *.md) + site=true + ;; + *) + # Unknown repository changes run every gate instead of risking a false skip. + android=true + ios=true + quality=true + site=true + web=true + ;; + esac + done < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + fi + + { + echo "android=$android" + echo "ios=$ios" + echo "quality=$quality" + echo "site=$site" + echo "web=$web" + } >> "$GITHUB_OUTPUT" + + { + echo '### CI change classification' + echo + echo "| Gate | Run |" + echo "| --- | --- |" + echo "| Android | $android |" + echo "| iOS | $ios |" + echo "| Core quality | $quality |" + echo "| Site | $site |" + echo "| Web | $web |" + } >> "$GITHUB_STEP_SUMMARY" - android-api-level-test: - runs-on: macos-latest - needs: android-build - name: Android Test + android-build: + name: Android Build (newArch=${{ matrix.new-arch }}) + needs: changes + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 strategy: + fail-fast: true matrix: - api-level: [24, 25, 29, 30, 31] - target: [default] + new-arch: [false, true] steps: - name: Checkout the code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - name: Verify Android Changed files - uses: tj-actions/changed-files@v46 - id: verify-android-changed-files - with: - files: | - android/** - src/** - assets/** - package.json - !example/ios/** - example/e2e/** - - - uses: actions/cache@v3 - name: Cache node_modules - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - id: cache-node-modules + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Set up JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - path: | - node_modules - example/node_modules - fail-on-cache-miss: true - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - - - uses: actions/cache@v3 - name: Cache Gradle dependencies - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - id: cache-gradle + distribution: zulu + java-version: 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - fail-on-cache-miss: true - key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/src/**/*.kt') }} + cache-provider: basic + cache-read-only: ${{ github.event_name == 'pull_request' }} - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Build package + run: yarn prepare + + - name: Build Android and run unit tests + working-directory: example/android + run: | + ./gradlew test assembleRelease --stacktrace -PnewArchEnabled=${{ matrix.new-arch }} + mv app/build/outputs/apk/release/app-release.apk app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk + + - name: Upload Android reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - ruby-version: 2.7 - bundler-cache: true + name: Android-Build-Reports-new-arch-${{ matrix.new-arch }} + path: example/android/**/build/reports + if-no-files-found: ignore + retention-days: 7 - - name: Setup node - uses: actions/setup-node@v3 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Upload APK + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - node-version: '18' + name: app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }} + path: example/android/app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk + if-no-files-found: error + retention-days: 7 + + android-api-level-test: + name: Android New Architecture Runtime Test (API ${{ matrix.api-level }}) + needs: [changes, android-build] + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: ${{ github.event_name == 'pull_request' }} + max-parallel: 2 + matrix: + api-level: ${{ fromJSON(github.event_name == 'pull_request' && '[31]' || '[24,25,29,30,31]') }} + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup - name: Set up JDK - uses: actions/setup-java@v3 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - distribution: 'zulu' + distribution: zulu java-version: 17 - - name: Instrumentation Tests - uses: reactivecircus/android-emulator-runner@v2 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + cache-provider: basic + cache-read-only: true + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build package + run: yarn prepare + + - name: Run instrumentation tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 with: api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} + target: default arch: x86_64 profile: Nexus 6 - script: | - cd example/android && ./gradlew connectedCheck --stacktrace + script: sh scripts/run-android-runtime-test.sh ${{ matrix.api-level }} - - name: Upload Reports - uses: actions/upload-artifact@v3 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Upload instrumentation reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: Test-Reports - path: ${{ github.workspace }}/example/android/app/build/reports + name: New-Arch-Runtime-Test-Reports-${{ matrix.api-level }} + path: | + example/android/app/build/reports + example/android/app/build/outputs/androidTest-results + if-no-files-found: ignore + retention-days: 7 ios-build-test: - runs-on: macos-latest - needs: install-dep - name: iOS Build and Test + name: iOS (newArch=${{ matrix.new-arch }}) + needs: changes + if: needs.changes.outputs.ios == 'true' + runs-on: macos-14 + timeout-minutes: 45 strategy: + fail-fast: ${{ github.event_name == 'pull_request' }} + max-parallel: 2 matrix: - cocoapods: ['1.10.1', '1.11.0', '1.14.3'] + new-arch: ['0', '1'] + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Set up Ruby + uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1 + with: + ruby-version: '3.0' + bundler-cache: true + working-directory: example + + - name: Cache Pods + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + example/ios/Pods + ~/Library/Caches/CocoaPods + key: ${{ runner.os }}-pods-no-flipper-new-arch-${{ matrix.new-arch }}-${{ hashFiles('example/ios/Podfile.lock', 'example/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-no-flipper-new-arch-${{ matrix.new-arch }}- + + - name: Install Pods + env: + NO_FLIPPER: '1' + RCT_NEW_ARCH_ENABLED: ${{ matrix.new-arch }} + run: | + cd example/ios + bundle exec pod install + + - name: Select an available iPhone simulator + id: simulator + run: | + simulator_udid="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ { print $2; exit }')" + if [[ -z "$simulator_udid" ]]; then + echo 'No available iPhone simulator was found' >&2 + exit 1 + fi + echo "udid=$simulator_udid" >> "$GITHUB_OUTPUT" + + - name: Test + env: + SIMULATOR_UDID: ${{ steps.simulator.outputs.udid }} + run: | + set -o pipefail + cd example/ios + xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" test | bundle exec xcpretty + + - name: Upload iOS test results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: iOS-Test-Results-new-arch-${{ matrix.new-arch }} + path: ${{ runner.temp }}/ios-tests.xcresult + if-no-files-found: ignore + retention-days: 7 + + web-test: + name: Web WASM and Browser Test + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - name: Checkout the code - uses: actions/checkout@v4 - - - name: Verify iOS Changed files - uses: tj-actions/changed-files@v46 - id: verify-iOS-changed-files - with: - files: | - ios/** - src/** - assets/** - package.json - !example/android/** - example/e2e/** - - - uses: actions/cache@v3 - name: Cache node_modules - id: cache-node-modules - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - with: - path: | - node_modules - example/node_modules - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - fail-on-cache-miss: true - - - name: Cache Pods - id: cache-pods - uses: actions/cache@v3 - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - with: - path: example/ios/Pods - key: ${{ runner.os }}-pods-${{ matrix.cocoapods }}-${{ hashFiles('**/Podfile.lock') }} - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - with: - ruby-version: 2.7 - bundler-cache: true - - - name: Install Cocoapods - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: gem install cocoapods -v ${{ matrix.cocoapods }} - - - name: Setup node - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install Pods - if: steps.cache-pods.outputs.cache-hit != 'true' && steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: | - cd example/ios - pod cache clean --all - pod install - - - name: Install xcpretty - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: gem install xcpretty - - - name: Build - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: | - cd example/ios - xcodebuild -workspace ImageMarkerExample.xcworkspace -scheme ImageMarkerExample -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 12' | xcpretty - - - name: Test - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: | - cd example/ios - xcodebuild -workspace ImageMarkerExample.xcworkspace -scheme ImageMarkerExample -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 12' test | xcpretty + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Build package + run: yarn prepare + + - name: Run Web tests + env: + CHROME_PATH: /usr/bin/google-chrome + run: | + yarn test:web + yarn test:web:browser + yarn test:web:metro + + - name: Verify npm package contents + run: npm pack --dry-run --ignore-scripts + + quality: + name: Lint, TypeScript, and Jest + needs: changes + if: needs.changes.outputs.quality == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Run core quality gates + run: | + yarn prepare + yarn typecheck + yarn lint + yarn test --runInBand + + site-test: + name: Documentation Site and Playground Test + needs: changes + if: needs.changes.outputs.site == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout the code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup + + - name: Build documentation site + run: yarn site:build + + - name: Validate site and Playground + env: + CHROME_PATH: /usr/bin/google-chrome + run: | + yarn site:test + yarn site:test:browser ci-complete: name: Complete CI - needs: [android-build, android-api-level-test, ios-build-test] - if: ${{ always() }} + needs: [changes, quality, android-build, android-api-level-test, ios-build-test, web-test, site-test] + if: always() runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - name: Check all job status - if: >- - ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + - name: Check all job statuses + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 + + - name: Confirm completion + if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + run: echo 'All required CI gates completed successfully.' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..e97eba4 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,108 @@ +name: Publish npm package + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + publish-npm: + name: Publish to npm with OIDC + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout the release tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js for trusted publishing + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org/ + package-manager-cache: false + + - name: Setup package managers + run: | + corepack enable + npm install --global npm@12.0.1 + + - name: Install dependencies without release caches + run: yarn install --immutable + + - name: Verify release identity + id: release + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + package_version="$(node -p "require('./package.json').version")" + if [[ "$RELEASE_TAG" != "v$package_version" ]]; then + echo "Release tag $RELEASE_TAG does not match package version $package_version" >&2 + exit 1 + fi + + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "Release tag $RELEASE_TAG is not reachable from main" >&2 + exit 1 + fi + + published_version="$(npm view "react-native-bs-diff-patch@$package_version" version 2>/dev/null || true)" + if [[ "$published_version" == "$package_version" ]]; then + echo "react-native-bs-diff-patch@$package_version is already published" >&2 + exit 1 + fi + + if [[ "$package_version" == *-* ]]; then + prerelease="${package_version#*-}" + dist_tag="${prerelease%%.*}" + else + dist_tag=latest + fi + if [[ ! "$dist_tag" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo "Derived npm dist-tag $dist_tag is invalid" >&2 + exit 1 + fi + echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" + echo "package_version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Run release quality gates + run: | + yarn prepare + yarn typecheck + yarn lint + yarn test --runInBand + yarn test:web + yarn test:web:browser + yarn test:web:metro + npm pack --dry-run --ignore-scripts + env: + CHROME_PATH: /usr/bin/google-chrome + + - name: Publish with provenance + env: + DIST_TAG: ${{ steps.release.outputs.dist_tag }} + run: npm publish --provenance --access public --tag "$DIST_TAG" + + - name: Verify registry provenance metadata + run: | + package_version="${{ steps.release.outputs.package_version }}" + for _ in $(seq 1 12); do + predicate_type="$(npm view "react-native-bs-diff-patch@$package_version" dist.attestations.provenance.predicateType 2>/dev/null || true)" + if [[ "$predicate_type" == "https://slsa.dev/provenance/v1" ]]; then + echo "Verified provenance for react-native-bs-diff-patch@$package_version" + exit 0 + fi + sleep 5 + done + echo 'Published package did not expose provenance metadata in time.' >&2 + exit 1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..38cb510 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,74 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main, feat/new_arch] + paths: + - '.github/workflows/pages.yml' + - 'docs/**' + - 'site/**' + - 'web/**' + - 'scripts/build-site.mjs' + - 'scripts/test-site.mjs' + - 'scripts/test-site-browser.mjs' + - 'package.json' + - 'yarn.lock' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '18' + cache: yarn + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Build documentation site + run: yarn site:build + + - name: Validate site structure + run: yarn site:test + + - name: Test Playground in Chrome + env: + CHROME_PATH: /usr/bin/google-chrome + run: yarn site:test:browser + + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: site-dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.gitignore b/.gitignore index d3b53df..80bf578 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ android/keystores/debug.keystore # generated by bob lib/ +site-dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7b98e0..08327f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,26 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` +Web implementation changes should also pass: + +```sh +yarn test:web +yarn test:web:browser +yarn test:web:metro +``` + +Documentation and site changes should pass: + +```sh +yarn site:build +yarn site:test +yarn site:test:browser +``` + +Public English guides live in `docs/`, with their Chinese mirrors in +`docs/zh-CN/`. Keep both languages aligned when a change affects API behavior, +platform support, errors, or operational guidance. + ### Commit message convention We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: @@ -89,14 +109,27 @@ Our pre-commit hooks verify that the linter and tests pass when committing. ### Publishing to npm -We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. +We use [release-it](https://github.com/release-it/release-it) to bump the +version, create the tag, and publish the GitHub Release. Publishing that release +starts `.github/workflows/npm-publish.yml`, which publishes to npm through OIDC +Trusted Publishing and verifies the package provenance. No long-lived npm token +is stored in GitHub. -To publish new versions, run the following: +Maintainers should run the quality gates, then create the release: ```sh +yarn prepare +yarn typecheck +yarn lint +yarn test --runInBand yarn release ``` +The npm package already trusts the `JimmyDaddy/react-native-bs-diff-patch` +repository and the `npm-publish.yml` workflow. No npm-side configuration is +required for a release. The release tag must exactly match +`v`. + ### Scripts The `package.json` file contains various scripts for common tasks: @@ -108,6 +141,13 @@ The `package.json` file contains various scripts for common tasks: - `yarn example start`: start the Metro server for the example app. - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +- `yarn build:web`: regenerate the checked-in WebAssembly bundle with Emscripten. +- `yarn test:web`: verify the WebAssembly patch format and round trip. +- `yarn test:web:browser`: exercise the public Web Worker API in Chrome. +- `yarn test:web:metro`: verify Metro resolves the React Native Web entry. +- `yarn site:build`: render public Markdown and static site assets into `site-dist/`. +- `yarn site:test`: validate site structure and local links. +- `yarn site:test:browser`: verify the live Playground, docs, and mobile viewport. ### Sending a pull request diff --git a/README.md b/README.md index 7b50269..07348cb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,26 @@ # react-native-bs-diff-patch -rn bs diff patch file +Create a compact binary patch from two versions of a file, then reconstruct the +new version from the old file and that patch. Android, iOS, and React Native Web +all use the compatible `ENDSLEY/BSDIFF43` wire format. + +[Documentation](https://bs-dff-patch.corerobin.com/docs/) · +[Playground](https://bs-dff-patch.corerobin.com/#playground) · +[中文说明](./README.zh-CN.md) · [npm](https://www.npmjs.com/package/react-native-bs-diff-patch) + +## Why use it? + +- **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. +- **No Web service required:** the browser implementation is the same bundled C + core compiled to WebAssembly. + +| Runtime | Input model | Create a patch | Apply a patch | +| ------------ | ------------------ | -------------- | ------------- | +| Android, iOS | Absolute paths | `diff` | `patch` | +| Web | In-memory binaries | `diffBytes` | `patchBytes` | ## Installation @@ -8,30 +28,110 @@ rn bs diff patch file npm install react-native-bs-diff-patch ``` -## Usage +After adding the package, install iOS pods and rebuild the native app: + +```sh +npx pod-install +``` + +React Native autolinking handles native registration. A Metro reload alone is +not enough after adding a native dependency. + +## Native quick start -```js +The native API works with absolute file paths. Use the filesystem library +already present in your app to select a writable cache directory. + +```ts import { diff, patch } from 'react-native-bs-diff-patch'; -// ... +type NativeRoundTripOptions = { + oldFilePath: string; + newFilePath: string; + cacheDirectory: string; +}; + +export async function nativeRoundTrip({ + oldFilePath, + newFilePath, + cacheDirectory, +}: NativeRoundTripOptions) { + const runId = Date.now(); + const patchPath = `${cacheDirectory}/update-${runId}.patch`; + const restoredPath = `${cacheDirectory}/restored-${runId}.bin`; + + await diff(oldFilePath, newFilePath, patchPath); + await patch(oldFilePath, restoredPath, patchPath); + + return { patchPath, restoredPath }; +} +``` + +Output paths must not exist, all paths in one call must be different, and the +required input files must already exist. Both functions resolve to `0` on +success. + +## React Native Web quick start -/** - * generate patch file from old file and new file - */ -await diff(oldFile, newFile, patchFile); -// generate new file from old file and patch file -await patch(oldFile, newFile, patchFile); +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; +export async function webRoundTrip(oldFile: File, newFile: File) { + const oldData = await oldFile.arrayBuffer(); + const newData = await newFile.arrayBuffer(); + const patchData = await diffBytes(oldData, newData); + const restoredData = await patchBytes(oldData, patchData); + + return { patchData, restoredData }; +} ``` +`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. + +## 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 | + +Calling an API family that is unavailable on the current platform rejects with +`EUNSUPPORTED` instead of silently choosing different behavior. + +## Production checklist + +- Verify the restored output before replacing application data. +- Authenticate patches from remote or otherwise untrusted sources. +- Use unique native output paths and clean temporary files after success or failure. +- Set product-specific input-size and time limits; binary diffing can use + several times the input size in peak memory. +- Generate and apply patches with this library. Generic `BSDIFF40` patches are + not interchangeable with `ENDSLEY/BSDIFF43` patches. + +See [Production recipes](./docs/recipes.md) for error handling, downloads, +cross-runtime patch exchange, and integrity checks. + +## Documentation + +- [Getting started](./docs/getting-started.md) +- [API reference](./docs/api-reference.md) +- [Production recipes](./docs/recipes.md) +- [Platform support](./docs/platform-support.md) +- [Architecture and patch format](./docs/architecture.md) +- [Troubleshooting](./docs/troubleshooting.md) +- [Development and verification](./docs/development.md) + ## Contributing -See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the local workflow and quality +gates. ## License MIT - ---- - -Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..de8eafc --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,129 @@ +# react-native-bs-diff-patch + +根据文件的两个版本生成紧凑的二进制补丁,再用旧文件和补丁还原新文件。 +Android、iOS 与 React Native Web 共用兼容的 `ENDSLEY/BSDIFF43` 补丁格式。 + +[中文文档](https://bs-dff-patch.corerobin.com/docs/zh-CN/) · +[在线 Playground](https://bs-dff-patch.corerobin.com/#playground) · +[English](./README.md) · [npm](https://www.npmjs.com/package/react-native-bs-diff-patch) + +## 为什么使用它? + +- **统一补丁格式:** 可以在一个受支持的运行时生成补丁,在另一个运行时应用。 +- **兼容 RN 两种架构:** 同时支持旧桥接架构和 TurboModule / 新架构。 +- **默认不阻塞 UI:** 原生端使用专用串行队列,Web 端使用独立模块 Worker。 +- **Web 无需后端服务:** 浏览器直接运行由同一套 C 核心编译而来的 WebAssembly。 + +| 运行时 | 输入方式 | 生成补丁 | 应用补丁 | +| ------------ | -------------- | ----------- | ------------ | +| Android、iOS | 绝对文件路径 | `diff` | `patch` | +| Web | 内存二进制数据 | `diffBytes` | `patchBytes` | + +## 安装 + +```sh +npm install react-native-bs-diff-patch +``` + +添加依赖后,iOS 还需要安装 Pods,并重新构建原生应用: + +```sh +npx pod-install +``` + +React Native autolinking 会完成原生模块注册。新增原生依赖后,只刷新 Metro +不能让模块进入已经安装的应用二进制。 + +## 原生端快速开始 + +原生 API 使用绝对文件路径。请通过项目已经使用的文件系统库选择可写缓存目录。 + +```ts +import { diff, patch } from 'react-native-bs-diff-patch'; + +type NativeRoundTripOptions = { + oldFilePath: string; + newFilePath: string; + cacheDirectory: string; +}; + +export async function nativeRoundTrip({ + oldFilePath, + newFilePath, + cacheDirectory, +}: NativeRoundTripOptions) { + const runId = Date.now(); + const patchPath = `${cacheDirectory}/update-${runId}.patch`; + const restoredPath = `${cacheDirectory}/restored-${runId}.bin`; + + await diff(oldFilePath, newFilePath, patchPath); + await patch(oldFilePath, restoredPath, patchPath); + + return { patchPath, restoredPath }; +} +``` + +输出路径不能已经存在,同一次调用中的所有路径必须不同,所需输入文件必须已经 +写入完成。两个函数成功时都返回 `0`。 + +## React Native Web 快速开始 + +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; + +export async function webRoundTrip(oldFile: File, newFile: File) { + const oldData = await oldFile.arrayBuffer(); + const newData = await newFile.arrayBuffer(); + const patchData = await diffBytes(oldData, newData); + const restoredData = await patchBytes(oldData, patchData); + + return { patchData, restoredData }; +} +``` + +`diffBytes` 和 `patchBytes` 接受 `ArrayBuffer`、任意 `ArrayBufferView` +(包括 TypedArray 和 `DataView`)或 `Blob`。它们返回新的 `Uint8Array`,且不会 +转移或失效调用方传入的缓冲区。 + +## 平台能力矩阵 + +| API | Android | iOS | Web | +| --------------------------------------- | ------- | ------ | ------ | +| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 | +| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 | +| `diffBytes(oldData, newData)` | 不支持 | 不支持 | 支持 | +| `patchBytes(oldData, patchData)` | 不支持 | 不支持 | 支持 | +| 旧架构 | 支持 | 支持 | 不适用 | +| 新架构 / TurboModule | 支持 | 支持 | 不适用 | + +调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他行为。 + +## 生产环境检查清单 + +- 替换业务数据前,验证还原结果与目标文件完全一致。 +- 对远程或其他不可信来源的补丁进行来源认证和完整性校验。 +- 原生端使用唯一输出路径,并在成功或失败后清理临时文件。 +- 按业务设置输入大小和执行时间限制;二进制差分的峰值内存可能达到输入大小的数倍。 +- 使用本库配套生成和应用补丁;通用 `BSDIFF40` 补丁与 + `ENDSLEY/BSDIFF43` 不兼容。 + +错误处理、补丁下载、跨运行时交换和完整性校验示例见 +[生产实践](./docs/zh-CN/recipes.md)。 + +## 完整文档 + +- [快速开始](./docs/zh-CN/getting-started.md) +- [API 参考](./docs/zh-CN/api-reference.md) +- [生产实践](./docs/zh-CN/recipes.md) +- [平台支持](./docs/zh-CN/platform-support.md) +- [架构与补丁格式](./docs/zh-CN/architecture.md) +- [常见问题与排障](./docs/zh-CN/troubleshooting.md) +- [开发与验证](./docs/zh-CN/development.md) + +## 参与贡献 + +本地开发流程和质量门禁见 [CONTRIBUTING.md](./CONTRIBUTING.md)。 + +## License + +MIT diff --git a/android/build.gradle b/android/build.gradle index 475489d..c30804f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,3 +1,5 @@ +import groovy.json.JsonSlurper + buildscript { def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["BsDiffPatch_kotlinVersion"] repositories { @@ -15,11 +17,42 @@ def isNewArchitectureEnabled() { return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" } +def getReactNativeMinorVersion() { + def candidates = [ + rootProject.file("../node_modules/react-native/package.json"), + project.file("../../react-native/package.json") + ] + def packageJson = candidates.find { it.exists() } + + if (packageJson == null) { + throw new GradleException("Unable to resolve react-native/package.json") + } + + def version = new JsonSlurper().parseText(packageJson.text).version + def match = version =~ /^(\d+)\.(\d+)\./ + + if (!match.find()) { + throw new GradleException("Unsupported React Native version: ${version}") + } + + return match.group(2).toInteger() +} + +def getNewArchitecturePackageSourceDir() { + return getReactNativeMinorVersion() >= 74 ? "newarch74/java" : "newarch73/java" +} + apply plugin: "com.android.library" apply plugin: "kotlin-android" if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" + + react { + jsRootDir = file("../src") + libraryName = "RNBsDiffPatchSpec" + codegenJavaPackageName = "com.jimmydaddy.bsdiffpatch" + } } def getExtOrDefault(name) { @@ -42,11 +75,18 @@ def supportsNamespace() { android { if (supportsNamespace()) { namespace "com.jimmydaddy.bsdiffpatch" + } - sourceSets { - main { + sourceSets { + main { + if (supportsNamespace()) { manifest.srcFile "src/main/AndroidManifestNew.xml" } + java.setSrcDirs( + isNewArchitectureEnabled() + ? ["src/main/java", "newarch/java", getNewArchitecturePackageSourceDir()] + : ["src/main/java", "oldarch/java"] + ) } } @@ -100,4 +140,3 @@ dependencies { implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" } - diff --git a/android/cpp-adapter.cpp b/android/cpp-adapter.cpp index 3055cd6..40bff07 100644 --- a/android/cpp-adapter.cpp +++ b/android/cpp-adapter.cpp @@ -1,12 +1,7 @@ #include #include "react-native-bs-diff-patch.h" -extern "C" - -JNIEXPORT jint JNICALL -Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchModule_00024Companion_bsDiffFile(JNIEnv *env, - jobject type, jstring oldFile_, - jstring newFile_, jstring patchFile_) { +static jint bsDiffFileJNI(JNIEnv *env, jstring oldFile_, jstring newFile_, jstring patchFile_) { const char *oldFile = env->GetStringUTFChars(oldFile_, 0); const char *newFile = env->GetStringUTFChars(newFile_, 0); const char *patchFile = env->GetStringUTFChars(patchFile_, 0); @@ -20,10 +15,7 @@ Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchModule_00024Companion_bsDiffFile(JNIE return result; } -extern "C" JNIEXPORT jint JNICALL -Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchModule_00024Companion_bsPatchFile(JNIEnv *env, - jobject type, jstring oldFile_, - jstring newFile_, jstring patchFile_) { +static jint bsPatchFileJNI(JNIEnv *env, jstring oldFile_, jstring newFile_, jstring patchFile_) { const char *oldFile = env->GetStringUTFChars(oldFile_, 0); const char *newFile = env->GetStringUTFChars(newFile_, 0); const char *patchFile = env->GetStringUTFChars(patchFile_, 0); @@ -36,3 +28,21 @@ Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchModule_00024Companion_bsPatchFile(JNI return result; } + +extern "C" JNIEXPORT jint JNICALL +Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchNative_bsDiffFile(JNIEnv *env, + jobject type, + jstring oldFile_, + jstring newFile_, + jstring patchFile_) { + return bsDiffFileJNI(env, oldFile_, newFile_, patchFile_); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_jimmydaddy_bsdiffpatch_BsDiffPatchNative_bsPatchFile(JNIEnv *env, + jobject type, + jstring oldFile_, + jstring newFile_, + jstring patchFile_) { + return bsPatchFileJNI(env, oldFile_, newFile_, patchFile_); +} diff --git a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt new file mode 100644 index 0000000..0006c00 --- /dev/null +++ b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -0,0 +1,44 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule + +@ReactModule(name = BsDiffPatchNative.NAME) +class BsDiffPatchModule(reactContext: ReactApplicationContext) : + NativeBsDiffPatchSpec(reactContext) { + private val taskRunner = BsDiffPatchTaskRunner() + + override fun getName(): String = BsDiffPatchNative.NAME + + override fun patch( + oldFile: String, + newFile: String, + patchFile: String, + promise: Promise + ) { + execute(promise) { + BsDiffPatchNative.patch(oldFile, newFile, patchFile) + } + } + + override fun diff( + oldFile: String, + newFile: String, + patchFile: String, + promise: Promise + ) { + execute(promise) { + BsDiffPatchNative.diff(oldFile, newFile, patchFile) + } + } + + private fun execute(promise: Promise, block: () -> Int) { + taskRunner.execute(promise, block) + } + + override fun invalidate() { + taskRunner.shutdown() + super.invalidate() + } +} diff --git a/android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt new file mode 100644 index 0000000..76e17c8 --- /dev/null +++ b/android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt @@ -0,0 +1,35 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class BsDiffPatchPackage : TurboReactPackage() { + override fun getModule( + name: String, + reactContext: ReactApplicationContext + ): NativeModule? { + return if (name == BsDiffPatchNative.NAME) { + BsDiffPatchModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + BsDiffPatchNative.NAME to ReactModuleInfo( + BsDiffPatchNative.NAME, + BsDiffPatchModule::class.java.name, + false, + false, + false, + true + ) + ) + } + } +} diff --git a/android/newarch74/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/newarch74/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt new file mode 100644 index 0000000..5e65b0b --- /dev/null +++ b/android/newarch74/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt @@ -0,0 +1,35 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class BsDiffPatchPackage : BaseReactPackage() { + override fun getModule( + name: String, + reactContext: ReactApplicationContext + ): NativeModule? { + return if (name == BsDiffPatchNative.NAME) { + BsDiffPatchModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + BsDiffPatchNative.NAME to ReactModuleInfo( + BsDiffPatchNative.NAME, + BsDiffPatchModule::class.java.name, + false, + false, + false, + true + ) + ) + } + } +} diff --git a/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt new file mode 100644 index 0000000..0d04773 --- /dev/null +++ b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -0,0 +1,53 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.module.annotations.ReactModule + +@ReactModule(name = BsDiffPatchNative.NAME) +class BsDiffPatchModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + private val taskRunner = BsDiffPatchTaskRunner() + + override fun getName(): String = BsDiffPatchNative.NAME + + @ReactMethod + fun patch(oldFile: String?, newFile: String?, patchFile: String?, promise: Promise) { + execute(promise) { + BsDiffPatchNative.patch( + requireArgument(oldFile, "oldFile"), + requireArgument(newFile, "newFile"), + requireArgument(patchFile, "patchFile") + ) + } + } + + @ReactMethod + fun diff(oldFile: String?, newFile: String?, patchFile: String?, promise: Promise) { + execute(promise) { + BsDiffPatchNative.diff( + requireArgument(oldFile, "oldFile"), + requireArgument(newFile, "newFile"), + requireArgument(patchFile, "patchFile") + ) + } + } + + private fun requireArgument(value: String?, fieldName: String): String { + if (value.isNullOrEmpty()) { + throw BsDiffPatchException("EINVAL", "$fieldName can not be null or empty") + } + return value + } + + private fun execute(promise: Promise, block: () -> Int) { + taskRunner.execute(promise, block) + } + + override fun invalidate() { + taskRunner.shutdown() + super.invalidate() + } +} diff --git a/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt new file mode 100644 index 0000000..be2877d --- /dev/null +++ b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt @@ -0,0 +1,16 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BsDiffPatchPackage : ReactPackage { + override fun createNativeModules( + reactContext: ReactApplicationContext + ): List = listOf(BsDiffPatchModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> = emptyList() +} diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt deleted file mode 100644 index e397957..0000000 --- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.jimmydaddy.bsdiffpatch - -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.module.annotations.ReactModule -import com.jimmydaddy.bsdiffpatch.BsDiffPatchModule -import java.io.File - -@ReactModule(name = BsDiffPatchModule.NAME) -class BsDiffPatchModule(reactContext: ReactApplicationContext?) : - ReactContextBaseJavaModule(reactContext) { - override fun getName(): String { - return NAME - } - - private fun getFileDir(dir: String): String { - if (dir.startsWith("file://")) { - return dir.substring(7) - } - return dir - } - - // Example method - // See https://reactnative.dev/docs/native-modules-android - @ReactMethod - fun patch(oldFile: String?, newFile: String?, patchFile: String?, promise: Promise) { - if (oldFile == null || newFile == null || patchFile == null || oldFile.isEmpty() || newFile.isEmpty() || patchFile.isEmpty()) { - promise.reject("error", "oldFile, newFile, patchFile can not be null or empty") - return - } - if (oldFile == newFile || oldFile == patchFile || newFile == patchFile) { - promise.reject("error", "oldFile, newFile, patchFile can not be the same") - return - } - val oldFileObj = File(getFileDir(oldFile)) - if (!oldFileObj.exists()) { - promise.reject("error", "oldFile: $oldFile not exist") - return - } - val patchFileObj = File(getFileDir(patchFile)) - if (!patchFileObj.exists()) { - promise.reject("error", "patchFile: $patchFile not exist") - return - } - val newFileObj = File(getFileDir(newFile)) - if (newFileObj.exists()) { - promise.reject("error", "newFile: $newFile already exist") - return - } - try { - val result = bsPatchFile(oldFileObj.absolutePath, newFileObj.absolutePath, patchFileObj.absolutePath) - promise.resolve(result) - } catch (e: Exception) { - promise.reject("error", e.message) - } - } - - @ReactMethod - fun diff(oldFile: String?, newFile: String?, patchFile: String?, promise: Promise) { - if (oldFile == null || newFile == null || patchFile == null || oldFile.isEmpty() || newFile.isEmpty() || patchFile.isEmpty()) { - promise.reject("error", "oldFile, newFile, patchFile can not be null or empty") - return - } - if (oldFile == newFile || oldFile == patchFile || newFile == patchFile) { - promise.reject("error", "oldFile, newFile, patchFile can not be the same") - return - } - val oldFileObj = File(getFileDir(oldFile)) - if (!oldFileObj.exists()) { - promise.reject("error", "oldFile: $oldFile not exist") - return - } - val newFileObj = File(getFileDir(newFile)) - if (!newFileObj.exists()) { - promise.reject("error", "newFile: $newFile not exist") - return - } - val patchFileObj = File(getFileDir(patchFile)) - if (patchFileObj.exists()) { - promise.reject("error", "patchFile: $patchFile already exist") - return - } - try { - val result = bsDiffFile(oldFileObj.absolutePath, newFileObj.absolutePath, patchFileObj.absolutePath) - promise.resolve(result) - } catch (e: Exception) { - promise.reject("error", e.message) - } - } - - companion object { - const val NAME = "BsDiffPatch" - - init { - System.loadLibrary("react-native-bs-diff-patch") - } - - // Used to load the 'native-lib' library on application startup. - // get new file from old file and patch file - private external fun bsPatchFile(oldFile: String, newFile: String, patchFile: String): Int - - // generate patch file - private external fun bsDiffFile(oldFile: String, newFile: String, patchFile: String): Int - } -} diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt new file mode 100644 index 0000000..2bf283e --- /dev/null +++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt @@ -0,0 +1,95 @@ +package com.jimmydaddy.bsdiffpatch + +import java.io.File + +internal object BsDiffPatchNative { + const val NAME = "BsDiffPatch" + + init { + System.loadLibrary("react-native-bs-diff-patch") + } + + fun patch(oldFile: String, newFile: String, patchFile: String): Int { + validateNonEmpty(oldFile, "oldFile") + validateNonEmpty(newFile, "newFile") + validateNonEmpty(patchFile, "patchFile") + validateDistinct(oldFile, newFile, patchFile) + + val oldFileObj = File(normalizePath(oldFile)) + val newFileObj = File(normalizePath(newFile)) + val patchFileObj = File(normalizePath(patchFile)) + + if (!oldFileObj.exists()) { + throw BsDiffPatchException("ENOENT", "oldFile: $oldFile does not exist") + } + if (!patchFileObj.exists()) { + throw BsDiffPatchException("ENOENT", "patchFile: $patchFile does not exist") + } + if (newFileObj.exists()) { + throw BsDiffPatchException("EEXIST", "newFile: $newFile already exists") + } + + return bsPatchFile( + oldFileObj.absolutePath, + newFileObj.absolutePath, + patchFileObj.absolutePath + ) + } + + fun diff(oldFile: String, newFile: String, patchFile: String): Int { + validateNonEmpty(oldFile, "oldFile") + validateNonEmpty(newFile, "newFile") + validateNonEmpty(patchFile, "patchFile") + validateDistinct(oldFile, newFile, patchFile) + + val oldFileObj = File(normalizePath(oldFile)) + val newFileObj = File(normalizePath(newFile)) + val patchFileObj = File(normalizePath(patchFile)) + + if (!oldFileObj.exists()) { + throw BsDiffPatchException("ENOENT", "oldFile: $oldFile does not exist") + } + if (!newFileObj.exists()) { + throw BsDiffPatchException("ENOENT", "newFile: $newFile does not exist") + } + if (patchFileObj.exists()) { + throw BsDiffPatchException("EEXIST", "patchFile: $patchFile already exists") + } + + return bsDiffFile( + oldFileObj.absolutePath, + newFileObj.absolutePath, + patchFileObj.absolutePath + ) + } + + private fun validateNonEmpty(value: String, fieldName: String) { + if (value.isEmpty()) { + throw BsDiffPatchException("EINVAL", "$fieldName can not be null or empty") + } + } + + private fun validateDistinct(oldFile: String, newFile: String, patchFile: String) { + if (oldFile == newFile || oldFile == patchFile || newFile == patchFile) { + throw BsDiffPatchException( + "EINVAL", + "oldFile, newFile, patchFile can not be the same" + ) + } + } + + private fun normalizePath(path: String): String { + return if (path.startsWith("file://")) path.substring(7) else path + } + + @JvmStatic + private external fun bsPatchFile(oldFile: String, newFile: String, patchFile: String): Int + + @JvmStatic + private external fun bsDiffFile(oldFile: String, newFile: String, patchFile: String): Int +} + +internal class BsDiffPatchException( + val code: String, + override val message: String +) : IllegalArgumentException(message) diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt deleted file mode 100644 index fba9624..0000000 --- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.jimmydaddy.bsdiffpatch - -import com.facebook.react.ReactPackage -import com.facebook.react.bridge.NativeModule -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.uimanager.ViewManager - -class BsDiffPatchPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - val modules: MutableList = ArrayList() - modules.add(BsDiffPatchModule(reactContext)) - return modules - } - - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() - } -} diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt new file mode 100644 index 0000000..abfb908 --- /dev/null +++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt @@ -0,0 +1,31 @@ +package com.jimmydaddy.bsdiffpatch + +import com.facebook.react.bridge.Promise +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +internal class BsDiffPatchTaskRunner { + private val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "BsDiffPatchWorker") + } + + fun execute(promise: Promise, block: () -> Int) { + try { + executor.execute { + try { + promise.resolve(block()) + } catch (error: BsDiffPatchException) { + promise.reject(error.code, error.message, error) + } catch (error: Exception) { + promise.reject("EUNSPECIFIED", error.message, error) + } + } + } catch (error: RejectedExecutionException) { + promise.reject("EUNAVAILABLE", "BsDiffPatch module is no longer available", error) + } + } + + fun shutdown() { + executor.shutdown() + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b617052 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Documentation + +These Markdown files are the source of truth for the public documentation at +[bs-dff-patch.corerobin.com/docs](https://bs-dff-patch.corerobin.com/docs/). +The [Chinese documentation](./zh-CN/README.md) mirrors the same public guides. + +## Choose a path + +- **Integrating the library:** start with [Getting started](./getting-started.md), + then check [Platform support](./platform-support.md). +- **Shipping an updater:** use [Production recipes](./recipes.md) and review the + resource and trust boundaries in [Architecture](./architecture.md). +- **Investigating a failure:** find the rejected error in + [Troubleshooting](./troubleshooting.md), then confirm its contract in the + [API reference](./api-reference.md). +- **Contributing:** follow [Development](./development.md) and the repository + [contribution guide](../CONTRIBUTING.md). + +## Guides + +- [Getting started](./getting-started.md) — installation and a first native or Web round trip. +- [API reference](./api-reference.md) — signatures, inputs, outputs, and errors. +- [Production recipes](./recipes.md) — integrity, cleanup, downloads, and cross-runtime workflows. +- [Platform support](./platform-support.md) — architecture and bundler behavior. +- [Architecture](./architecture.md) — execution paths and patch compatibility. +- [Troubleshooting](./troubleshooting.md) — common integration failures. +- [Development](./development.md) — local builds, tests, WebAssembly, and release checks. + +Implementation review records live under [`docs/review`](./review/) and are not +part of the end-user documentation navigation. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..8c1ac65 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,133 @@ +# API reference + +The package exposes two platform-specific API families from the same import +path. Native runtimes use absolute paths; Web uses in-memory binary values. + +```ts +import { + diff, + patch, + diffBytes, + patchBytes, + type BinaryInput, +} from 'react-native-bs-diff-patch'; +``` + +## `diff` + +```ts +function diff( + oldFile: string, + newFile: string, + patchFile: string +): Promise; +``` + +Creates a binary patch at `patchFile`. Available on Android and iOS. + +- `oldFile`: existing baseline file path. +- `newFile`: existing target file path. +- `patchFile`: destination path that must not already exist. +- Resolves to `0` on success. +- Rejects rather than overwriting an existing `patchFile`. + +## `patch` + +```ts +function patch( + oldFile: string, + outputFile: string, + patchFile: string +): Promise; +``` + +Reconstructs the target file at `outputFile`. Available on Android and iOS. + +- `oldFile`: existing baseline file path. +- `outputFile`: destination path that must not already exist. The runtime + implementation names this argument `newFile`; its position and behavior are + the public contract. +- `patchFile`: existing compatible patch path. +- Resolves to `0` on success. +- Rejects rather than overwriting an existing `outputFile`. + +## `diffBytes` + +```ts +type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; + +function diffBytes( + oldData: BinaryInput, + newData: BinaryInput +): Promise; +``` + +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. + +## `patchBytes` + +```ts +function patchBytes( + oldData: BinaryInput, + patchData: BinaryInput +): Promise; +``` + +Applies a compatible patch in a Web Worker and resolves to the reconstructed +bytes. Available on Web. + +- Validates the patch header before invoking the WebAssembly core. +- Copies inputs and resolves to a new `Uint8Array`. +- Does not mutate `oldData` or `patchData`. + +## Availability behavior + +All four functions remain exported so shared code has one stable import shape. +Calling `diffBytes` or `patchBytes` on native rejects with `EUNSUPPORTED`. +Calling `diff` or `patch` on Web behaves the same way. + +Importing the Web entry during server-side rendering does not start a Worker. +Calling a binary API without browser Worker support rejects with +`EUNSUPPORTED`. + +## Error shape + +Rejected operations expose a normal `Error` with a string `code` when the +platform can classify the failure. + +```ts +type PatchError = Error & { code?: string }; +``` + +| Code | Meaning | +| -------------- | ----------------------------------------------------------- | +| `EINVAL` | Empty, duplicate, or invalid input. | +| `ENOENT` | A required native file does not exist. | +| `EEXIST` | A native output path already exists. | +| `EUNSUPPORTED` | The selected API is not available on the current platform. | +| `EUNAVAILABLE` | The native module worker has already shut down. | +| `EWEBASSEMBLY` | WebAssembly loading, patch validation, or execution failed. | +| `EUNSPECIFIED` | An unclassified native exception occurred. | + +Treat error messages as diagnostic text rather than a stable machine-readable +contract. Branch on `code` when recovery behavior differs. + +Native validation stops before entering the C core. Web failures related to +Worker startup, patch validation, or WebAssembly execution use +`EWEBASSEMBLY` unless a more specific code is available. + +## 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. + +## Patch format + +All four operations read or write `ENDSLEY/BSDIFF43` patches. Other bsdiff +variants, such as patches beginning with `BSDIFF40`, are not interchangeable. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..65f1026 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,88 @@ +# Architecture and patch format + +The library keeps one patch implementation and exposes it through three runtime +adapters. + +## Execution paths + +```text +React Native JavaScript + -> typed public API + -> TurboModule or legacy bridge + -> platform-owned serial worker queue + -> JNI / Objective-C++ + -> shared bsdiff + bzip2 C sources + +React Native Web + -> typed public API + -> module Web Worker + -> Emscripten MEMFS + -> the same bsdiff + bzip2 C sources compiled to WebAssembly +``` + +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. + +## Patch wire format + +Patches begin with a 24-byte header: + +| Bytes | Content | +| -------- | ---------------------------------------------------- | +| `0..15` | ASCII magic `ENDSLEY/BSDIFF43` | +| `16..23` | Signed 64-bit target size in the format's byte order | +| `24..` | bzip2-compressed control, diff, and extra data | + +The Web adapter validates the header and signature before entering the C patch +function. Native and Web operations use the same checked-in bsdiff and bzip2 +sources, preserving cross-platform patch compatibility. + +The format identifies the patch implementation, but not the intended baseline +or release. Applications should carry baseline and target digests in a trusted +manifest when distributing patches. + +## WebAssembly packaging + +`scripts/build-web-wasm.sh` invokes Emscripten with: + +- an ES module factory; +- a single-file embedded WebAssembly payload; +- memory growth enabled; +- MEMFS and the `FS`/`ccall` runtime methods; +- exported `bsDiffFile` and `bsPatchFile` functions. + +The generated `web/bsdiffpatch.mjs` is published with the package. Consumers do +not need Emscripten. + +## Memory model + +Native operations read the old and target files into process memory. Web calls +copy inputs before transferring them to a Worker, then copy results out of +MEMFS. Peak memory can therefore be several times larger than the input or +output size. + +For very large updates, enforce an application limit before calling the +library, and consider a server-side or streaming update strategy when the full +files cannot safely fit in memory. + +## Ownership boundaries + +The library owns patch computation and platform scheduling. The application +owns: + +- file selection, storage permissions, and temporary-file cleanup; +- patch transport and cache policy; +- authentication and cryptographic integrity checks; +- concurrency, size, and time limits; +- verification and atomic replacement of the restored output. + +Keeping these responsibilities outside the patch engine lets applications use +their existing filesystem and release trust model. + +## Compatibility rule + +Patch compatibility is defined by the magic and implementation, not merely by +the generic name “bsdiff.” A `BSDIFF40` patch from another package is not a +supported input. Generate and apply patches with this library when crossing +Android, iOS, and Web. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..72507b0 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,96 @@ +# Development and verification + +## Prerequisites + +- Node.js 18 or newer. +- Yarn 3.6.1 through the repository's checked-in Yarn release. +- Android Studio/JDK 17 for Android work. +- Xcode and CocoaPods for iOS work. +- Emscripten only when regenerating the checked-in WebAssembly bundle. + +## Install + +```sh +yarn install --immutable +``` + +The root package is the library and `example/` is the React Native consumer. + +## Core quality gates + +```sh +yarn prepare +yarn typecheck +yarn lint +yarn test --runInBand +``` + +## Web gates + +```sh +yarn test:web +yarn test:web:browser +yarn test:web:metro +``` + +- `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. + +## Site and documentation + +```sh +yarn site:build +yarn site:test +yarn site:test:browser +``` + +The static output is written to `site-dist/` and deployed by the GitHub Pages +workflow. Markdown under `docs/` is rendered into the site by the build script. +English pages live directly under `docs/`; Chinese mirrors live under +`docs/zh-CN/`. Keep both versions aligned when behavior or public API changes. + +## Rebuild WebAssembly + +After changing files under `cpp/`, activate an Emscripten toolchain and run: + +```sh +yarn build:web +yarn test:web +yarn test:web:browser +``` + +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. + +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. +3. Confirm public docs match the exported TypeScript declarations. +4. Confirm English and Chinese public guides describe the same behavior. +5. Use `yarn release` to create the version, tag, and GitHub Release. It does not + publish directly to npm. +6. Publishing the GitHub Release starts `npm-publish.yml`. The workflow checks + that the tag matches `package.json`, runs the release gates, publishes through + npm Trusted Publishing, and verifies the provenance attestation. +7. Verify the npm package and GitHub Release before announcing availability. + +The npm package's Trusted Publisher is already configured with these values: + +- Provider: GitHub Actions. +- Organization or user: `JimmyDaddy`. +- Repository: `react-native-bs-diff-patch`. +- Workflow filename: `npm-publish.yml`. +- Environment: leave empty. + +No npm-side change is required for a normal release, and the workflow does not +use a long-lived npm token. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d621057 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,131 @@ +# Getting started + +This guide installs the package, selects the correct API family, and completes +a patch round trip. + +## Install + +```sh +npm install react-native-bs-diff-patch +``` + +Install iOS pods after adding or updating the native dependency: + +```sh +npx pod-install +``` + +React Native autolinking handles Android and iOS registration. Rebuild the +native application after installation; reloading Metro does not change the +native modules inside an already-installed binary. + +## Choose the API for the runtime + +| Runtime | Use | Do not use | +| ------------ | ---------------------------- | ---------------- | +| Android, iOS | `diff` and `patch` | Binary-data APIs | +| Web | `diffBytes` and `patchBytes` | File-path APIs | + +The unavailable family rejects with `EUNSUPPORTED`, which helps catch imports +that resolved to an unexpected platform entry. + +## Native file workflow + +Native APIs operate on absolute file paths. The library does not choose a +storage directory or manage file lifetime; use the filesystem solution already +present in your application. + +```ts +import { diff, patch } from 'react-native-bs-diff-patch'; + +type NativeRoundTripOptions = { + oldFilePath: string; + newFilePath: string; + cacheDirectory: string; +}; + +export async function nativeRoundTrip({ + oldFilePath, + newFilePath, + cacheDirectory, +}: NativeRoundTripOptions) { + const runId = Date.now(); + const patchPath = `${cacheDirectory}/release-${runId}.patch`; + const restoredPath = `${cacheDirectory}/release-${runId}.restored`; + + await diff(oldFilePath, newFilePath, patchPath); + await patch(oldFilePath, restoredPath, patchPath); + + return { patchPath, restoredPath }; +} +``` + +Before calling `diff`: + +- `oldFilePath` and `newFilePath` must exist. +- `patchPath` must not exist. +- All three paths must be non-empty and different. + +Before calling `patch`: + +- `oldFilePath` and `patchPath` must exist. +- `restoredPath` must not exist. +- All three paths must be non-empty and different. + +Use a content hash or byte comparison from your filesystem layer to verify that +`restoredPath` matches `newFilePath`. Clean the patch and restored file when +they are no longer needed. + +## Web binary workflow + +React Native Web uses binary values instead of filesystem paths: + +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; + +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 matches = + restoredData.length === newData.length && + restoredData.every((byte, index) => byte === newData[index]); + +if (!matches) { + throw new Error('Patch round trip did not reproduce the target data'); +} +``` + +Inputs are copied before being transferred to the module Worker, so the +caller's buffers remain usable. Each result is a new `Uint8Array`. + +## Use browser files + +```ts +import { diffBytes } from 'react-native-bs-diff-patch'; + +export async function downloadPatch(oldFile: File, newFile: File) { + const patchData = await diffBytes(oldFile, newFile); + const url = URL.createObjectURL( + new Blob([patchData], { type: 'application/octet-stream' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'update.patch'; + link.click(); + URL.revokeObjectURL(url); +} +``` + +The Web API is client-only. Importing it during server-side rendering is safe, +but call it only after a browser `Worker` is available. + +## Next steps + +- Copy a recovery pattern from [Production recipes](./recipes.md). +- Review all signatures and error codes in the [API reference](./api-reference.md). +- Check [platform and bundler support](./platform-support.md). +- Try the [live Playground](https://bs-dff-patch.corerobin.com/#playground). diff --git a/docs/platform-support.md b/docs/platform-support.md new file mode 100644 index 0000000..057f532 --- /dev/null +++ b/docs/platform-support.md @@ -0,0 +1,71 @@ +# Platform support + +## Capability matrix + +| Capability | Android | iOS | React Native Web | +| ------------------------------ | ------------------ | --------------------- | ------------------ | +| File-path APIs | Yes | Yes | No | +| Binary-data APIs | No | No | Yes | +| Legacy bridge | Yes | Yes | N/A | +| TurboModule / New Architecture | Yes | Yes | N/A | +| 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. + +## Android + +Android selects a New Architecture package implementation based on the React +Native minor version: + +- React Native 0.73 uses the compatible `TurboReactPackage` source set. +- React Native 0.74 and newer use `BaseReactPackage`. +- Legacy architecture builds use the classic `ReactPackage` implementation. + +Native operations run on a module-owned single-thread executor. The packaged C +code is built with CMake and invoked through JNI. + +## iOS + +iOS autolinking registers `BsDiffPatch` for both architectures. New +Architecture codegen maps the module through `modulesProvider`, and the module +returns a generated TurboModule instance when `RCT_NEW_ARCH_ENABLED` is set. + +Operations run on a dedicated serial dispatch queue rather than the main queue. + +## React Native Web + +The package has two Web entry mechanisms: + +- `browser` points standard browser-aware bundlers to `web/index.mjs`. +- `src/index.web.ts` ensures Metro's platform resolver selects the Web API even + though React Native gives the `react-native` package field higher priority. + +The browser must support: + +- WebAssembly. +- Module Web Workers. +- `ArrayBuffer` and typed arrays. +- `Blob.arrayBuffer()` when `Blob` inputs are used. + +Webpack and Vite understand the standard +`new Worker(new URL(..., import.meta.url), { type: 'module' })` pattern. A Metro +Web setup must preserve module-worker URLs in its Web serializer. + +The Web entry is browser-oriented rather than a Node.js filesystem adapter. It +does not make the native file-path APIs available in Node.js. + +## Server-side rendering + +Importing the Web entry does not create a worker. Calling `diffBytes` or +`patchBytes` in an environment without `Worker` rejects with `EUNSUPPORTED`. +Invoke the binary APIs only in browser/client code. + +## Patch exchange + +Patch bytes are portable across Android, iOS, and Web. File access, transport, +storage, integrity verification, and final replacement remain application +responsibilities. See [Production recipes](./recipes.md) for a safe exchange +sequence. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..c25a3c2 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,100 @@ +# Production recipes + +These patterns cover the application responsibilities around the patch engine: +unique paths, cleanup, integrity, and platform boundaries. + +## Handle classified errors + +Error messages are diagnostic text. Use `code` for recovery decisions. + +```ts +type PatchError = Error & { code?: string }; + +export function isPatchError(error: unknown): error is PatchError { + return error instanceof Error; +} + +try { + await patch(oldPath, outputPath, patchPath); +} catch (error) { + if (isPatchError(error) && error.code === 'EEXIST') { + // Remove a known temporary output or retry with a new unique path. + } else if (isPatchError(error) && error.code === 'ENOENT') { + // Re-download or re-resolve the required old file or patch. + } else { + throw error; + } +} +``` + +Do not remove a user-owned destination merely because `EEXIST` was returned. +Only clean paths your application created as temporary outputs. + +## Exchange a patch across runtimes + +All platforms use `ENDSLEY/BSDIFF43`, so a valid workflow can cross runtime +boundaries: + +1. Generate a patch with `diff` on Android or iOS, or `diffBytes` on Web. +2. Store or transfer the patch as opaque binary data without text conversion. +3. Deliver the exact baseline file expected by that patch. +4. Apply it with the API family for the destination runtime. +5. Verify the restored bytes against a trusted target hash. + +The baseline identity matters as much as the patch. A valid patch applied to +the wrong baseline is not a supported update workflow. + +## Authenticate remote patches + +Transport security alone does not establish that a patch belongs to the +expected release. Distribute a signed manifest containing at least: + +- baseline version or baseline digest; +- patch digest and byte length; +- target digest and byte length; +- patch format identifier; +- release identifier and signature metadata. + +Verify the manifest and downloaded patch before applying it. Verify the restored +file before replacing application data. Cryptographic signing and hashing stay +outside this library so applications can use their existing trust model. + +## Use atomic replacement on native + +Write the reconstructed file to a unique path in the same storage area as the +final destination. After integrity verification, use the filesystem layer to +atomically rename or replace the destination when the platform supports it. +Never ask `patch` to overwrite the active file directly; output paths are +required to be unused. + +## Bound resource use + +The algorithm operates on complete buffers and peak memory can be several times +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; +- 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. + +## Download a Web patch + +Create an object URL, trigger the download, and revoke the URL after use: + +```ts +const patchData = await diffBytes(oldFile, newFile); +const url = URL.createObjectURL(new Blob([patchData])); +const link = Object.assign(document.createElement('a'), { + href: url, + download: 'release.patch', +}); +link.click(); +URL.revokeObjectURL(url); +``` + +Keep the bytes binary when uploading or storing them. Converting arbitrary +patch bytes through UTF-8 strings corrupts the data. diff --git a/docs/review/resolved_react-native-new-architecture-review-2026-07-18.md b/docs/review/resolved_react-native-new-architecture-review-2026-07-18.md new file mode 100644 index 0000000..710a874 --- /dev/null +++ b/docs/review/resolved_react-native-new-architecture-review-2026-07-18.md @@ -0,0 +1,101 @@ +# React Native 新架构支持评审 + +## 范围 + +- TurboModule Codegen 与 Android/iOS 注册实现。 +- `diff`、`patch` 的原生执行线程。 +- 示例工程和 CI 对新旧架构的覆盖。 + +## 总结 + +当前实现可在 React Native 0.73 与 0.86 新架构下构建,iOS 0.86 运行时调用也可进入原生模块;未发现 P0/P1 阻断问题。确认 3 项 P2 与 1 项 P3,均可执行修复。 + +## 详细问题 + +### P2:耗时计算占用 React Native 原生模块调用线程 + +- 证据:`android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt` 与 `ios/BsDiffPatch.mm` 在导出方法内直接执行 bsdiff/bspatch。 +- 影响:大文件可能长期占用 React Native 的原生模块执行队列,拖慢其他原生调用。 +- 建议:Android 使用模块自有单线程执行器;iOS 提供模块专用串行 `methodQueue`。 + +### P2:CI 未覆盖新架构且缺少有效 JS 测试 + +- 证据:`example/android/gradle.properties` 设置 `newArchEnabled=false`;Android/iOS CI 未传入新架构开关;`src/__tests__/index.test.tsx` 仅有 `it.todo`。 +- 影响:Codegen、自动链接与模块注册回归无法自动发现。 +- 建议:CI 增加新旧架构矩阵,并补充 JS 到 TurboModule 的委托测试。 + +### P2:iOS CI 使用错误的 workspace 与 scheme + +- 证据:`.github/workflows/ci.yml` 使用 `ImageMarkerExample`,实际工程为 `BsDiffPatchExample`。 +- 影响:iOS 构建与测试任务不能验证当前项目。 +- 建议:改为实际 workspace/scheme,并使用可用模拟器目标。 + +### P3:新架构注册依赖弃用或兼容层 API + +- 证据:Android 使用 `TurboReactPackage` 和旧 `ReactModuleInfo` 构造器;iOS `codegenConfig` 缺少 `modulesProvider`。 +- 影响:React Native 后续移除旧架构兼容代码时存在构建或注册断裂风险。 +- 建议:Android 按 RN 版本选择 `BaseReactPackage`/兼容实现并统一使用非弃用构造器;iOS 增加 `modulesProvider`。 + +## 已执行验证 + +- RN 0.73 Android 新架构库目标构建成功。 +- RN 0.86 Android 新架构消费者 APK 构建成功。 +- RN 0.86 iOS Pod 安装与 Debug/Release 构建成功。 +- RN 0.86 iOS 模拟器调用返回预期 `EINVAL`。 + +## 测试覆盖缺口 + +- Android 尚无设备级运行时调用测试。 +- 仓库质量门禁将在修复完成后重新执行。 + +## 复核结论(2026-07-18) + +整体结论:认可。 + +- 认可原生工作队列问题,按平台增加专用串行执行设施。 +- 认可新架构 CI 与测试覆盖问题,增加新旧架构矩阵和委托测试。 +- 认可 iOS CI 目标错误,修正 workspace/scheme。 +- 认可弃用注册风险,在保留 RN 0.73 兼容性的前提下使用版本化 Android 源集,并补 iOS 模块映射。 + +## 修复执行记录(2026-07-18) + +| 问题 | 状态 | 执行结果 | +| --- | --- | --- | +| 原生方法同步占用模块线程 | 已完成 | Android 新旧架构共用模块自有单线程执行器,并在模块失效时关闭;iOS 使用模块专用串行队列。 | +| CI 无新架构覆盖、JS 测试为空 | 已完成 | Android 与 iOS 增加新旧架构矩阵;补充 `diff`、`patch` TurboModule 委托测试;同步升级已停止支持的缓存与制品 Actions。 | +| iOS CI workspace/scheme 错误 | 已完成 | 改为 `BsDiffPatchExample.xcworkspace` / `BsDiffPatchExample`,构建使用通用模拟器目标。 | +| 新架构注册依赖弃用/兼容层 API | 已完成 | RN 0.73 保留 `TurboReactPackage` 兼容源集,RN 0.74+ 使用 `BaseReactPackage`;统一非弃用 `ReactModuleInfo` 构造器;iOS 增加 `modulesProvider`。 | + +### 修复后验证 + +- `yarn lint`:通过。 +- `yarn typecheck`:通过。 +- `yarn test --runInBand`:通过,2 个测试全部成功。 +- GitHub Actions YAML 解析:通过。 +- RN 0.73 Android:旧架构与新架构库目标均构建成功。 +- RN 0.73 iOS:Codegen 成功生成 `RNBsDiffPatchSpec`。 +- RN 0.86 Android:新架构消费者 APK 构建成功,使用 `BaseReactPackage`。 +- RN 0.86 iOS:Pod 安装、Release 模拟器构建成功;生成的 provider 映射包含 `BsDiffPatch`;模拟器调用进入原生模块并返回预期 `EINVAL`。 +- `git diff --check`:通过。 + +### 剩余非阻塞覆盖缺口 + +- Android 尚未增加新架构设备级运行时调用断言;当前 CI 已覆盖新架构 Codegen、自动链接和 APK 构建。 + +## 修复执行记录(2026-07-18,Android 设备级断言) + +状态:已完成。 + +- `example/src/App.tsx` 执行可观测的 `diff`/`patch` 往返,校验原生返回码及重建文件内容,并向 UI 暴露成功或错误状态。 +- `example/android/app/src/androidTest/java/com/bsdiffpatchexample/NewArchitectureRuntimeTest.kt` 启动 Release App,先断言 `BuildConfig.IS_NEW_ARCHITECTURE_ENABLED`,再等待并断言往返状态为成功。 +- `example/android/app/build.gradle` 配置 Release instrumentation 目标和 AndroidX 测试依赖;Release 包内置 JS bundle,不依赖 Metro。 +- `.github/workflows/ci.yml` 在 API 24、25、29、30、31 的 x86_64 模拟器上使用 `-PnewArchEnabled=true` 执行 `connectedReleaseAndroidTest`,并上传独立测试报告。 + +### 补充验证 + +- `:app:compileReleaseAndroidTestKotlin -PnewArchEnabled=true`:通过。 +- API 34 arm64 模拟器执行 `:app:connectedReleaseAndroidTest -PnewArchEnabled=true`:通过,1 个测试、0 失败;最终回归耗时约 3.6 秒。 +- `yarn lint`、`yarn typecheck`、`yarn test --runInBand`:全部通过。 +- GitHub Actions YAML 解析与 `git diff --check`:通过。 + +原“Android 尚无设备级运行时调用测试”的剩余覆盖缺口至此关闭。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..94e3079 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,88 @@ +# Troubleshooting + +Start with the error `code`, then confirm the runtime and input model: + +| Symptom | First check | +| -------------------------------- | ------------------------------------------------ | +| 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 | +| Worker or `EWEBASSEMBLY` failure | Check emitted Web assets, CSP, and patch magic | +| High memory use | Enforce input and concurrency limits | + +## `TurboModuleRegistry.getEnforcing(...): 'BsDiffPatch' could not be found` + +Rebuild the native application after installing the package. Metro reloads do +not add native modules to an already-installed binary. + +- iOS: run `npx pod-install`, clean the Xcode build if needed, and rebuild. +- Android: stop the app, clean stale Gradle outputs if needed, and rebuild. +- Confirm the installed JavaScript package and native binary come from the same + dependency state. + +## `ENOENT` + +A required file path does not exist. Verify that: + +- paths are absolute and point to app-accessible storage; +- the old and new files exist before `diff`; +- the old file and patch exist before `patch`; +- asynchronous file writes have completed before starting the operation. + +## `EEXIST` + +The native destination already exists. The library avoids silently overwriting +patches or reconstructed files. Remove the stale output or use a unique path. + +## `EINVAL` + +Paths may be empty or duplicated, or Web binary input may not be an accepted +type. Old, new/output, and patch paths must all differ. + +## `EUNSUPPORTED` on Web + +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. + +## Worker failed to load + +Confirm the bundler emits module-worker assets and that the deployed server +serves `.mjs` files as JavaScript. Strict Content Security Policy deployments +must permit same-origin workers and WebAssembly execution. + +Open the browser network panel and confirm `worker.mjs`, `operations.mjs`, and +`bsdiffpatch.mjs` are returned with successful status codes rather than the +application HTML fallback. + +## `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. + +## 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. + +## Restored output does not match + +Confirm that the patch was generated from the exact baseline bytes being used +for restoration. Preserve patches as opaque binary data and avoid string or +JSON conversion. Verify the patch digest before applying it and the target +digest before replacing application data. + +## Getting more diagnostics + +When opening an issue, include: + +- React Native and library versions; +- platform, architecture mode, and bundler; +- the rejected error `code` and message; +- minimal input sizes and path state without attaching sensitive files; +- whether the failure reproduces in the example app or online Playground. diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md new file mode 100644 index 0000000..b4fcf54 --- /dev/null +++ b/docs/zh-CN/README.md @@ -0,0 +1,27 @@ +# 文档 + +这里的 Markdown 是 +[bs-dff-patch.corerobin.com/docs/zh-CN](https://bs-dff-patch.corerobin.com/docs/zh-CN/) +中文文档的内容源。英文原文见 [Documentation](../README.md)。 + +## 按目标选择阅读路径 + +- **接入库:** 先阅读[快速开始](/docs/zh-CN/getting-started/),再确认 + [平台支持](/docs/zh-CN/platform-support/)。 +- **实现生产更新流程:** 使用[生产实践](/docs/zh-CN/recipes/),并理解 + [架构](/docs/zh-CN/architecture/)中的资源和信任边界。 +- **排查失败:** 先在[常见问题与排障](/docs/zh-CN/troubleshooting/)中按错误码 + 定位,再到 [API 参考](/docs/zh-CN/api-reference/)确认约定。 +- **参与贡献:** 按[开发与验证](/docs/zh-CN/development/)和仓库 + [贡献指南](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/CONTRIBUTING.md) + 执行。 + +## 指南 + +- [快速开始](/docs/zh-CN/getting-started/) — 安装并完成第一次原生端或 Web 往返。 +- [API 参考](/docs/zh-CN/api-reference/) — 签名、输入、输出和错误码。 +- [生产实践](/docs/zh-CN/recipes/) — 完整性、清理、下载与跨运行时流程。 +- [平台支持](/docs/zh-CN/platform-support/) — 架构与打包器行为。 +- [架构](/docs/zh-CN/architecture/) — 执行路径与补丁兼容性。 +- [常见问题与排障](/docs/zh-CN/troubleshooting/) — 常见集成失败。 +- [开发与验证](/docs/zh-CN/development/) — 本地构建、测试、WASM 与发布检查。 diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md new file mode 100644 index 0000000..b5684d5 --- /dev/null +++ b/docs/zh-CN/api-reference.md @@ -0,0 +1,119 @@ +# API 参考 + +包从同一个入口导出两组平台专用 API。原生运行时使用绝对路径,Web 使用内存中的 +二进制值。 + +```ts +import { + diff, + patch, + diffBytes, + patchBytes, + type BinaryInput, +} from 'react-native-bs-diff-patch'; +``` + +## `diff` + +```ts +function diff( + oldFile: string, + newFile: string, + patchFile: string +): Promise; +``` + +在 `patchFile` 创建二进制补丁,仅 Android 与 iOS 可用。 + +- `oldFile`:已存在的基线文件路径。 +- `newFile`:已存在的目标文件路径。 +- `patchFile`:必须尚不存在的输出路径。 +- 成功时返回 `0`,不会覆盖已有补丁文件。 + +## `patch` + +```ts +function patch( + oldFile: string, + outputFile: string, + patchFile: string +): Promise; +``` + +在 `outputFile` 还原目标文件,仅 Android 与 iOS 可用。 + +- `oldFile`:已存在的基线文件路径。 +- `outputFile`:必须尚不存在的目标路径。运行时实现将该参数命名为 `newFile`, + 参数位置与行为才是公开约定。 +- `patchFile`:已存在且兼容的补丁路径。 +- 成功时返回 `0`,不会覆盖已有输出文件。 + +## `diffBytes` + +```ts +type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; + +function diffBytes( + oldData: BinaryInput, + newData: BinaryInput +): Promise; +``` + +在 Web Worker 中生成补丁,仅 Web 可用。 + +- 接受 `ArrayBuffer`、任意 TypedArray、`DataView` 和 `Blob`。 +- 会复制输入,不会让调用方缓冲区失效。 +- 返回包含 `ENDSLEY/BSDIFF43` 补丁的新 `Uint8Array`。 + +## `patchBytes` + +```ts +function patchBytes( + oldData: BinaryInput, + patchData: BinaryInput +): Promise; +``` + +在 Web Worker 中应用兼容补丁,并返回还原后的字节。 + +- 进入 WebAssembly 核心前会校验补丁头。 +- 复制输入并返回新的 `Uint8Array`。 +- 不会修改 `oldData` 或 `patchData`。 + +## 平台不可用时的行为 + +四个函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes` 或 +`patchBytes`、在 Web 调用 `diff` 或 `patch`,都会以 `EUNSUPPORTED` 拒绝。 + +SSR 阶段导入 Web 入口不会启动 Worker;在没有浏览器 Worker 的环境调用二进制 +API 会以 `EUNSUPPORTED` 拒绝。 + +## 错误结构 + +当平台可以分类错误时,拒绝值是带字符串 `code` 的普通 `Error`。 + +```ts +type PatchError = Error & { code?: string }; +``` + +| 错误码 | 含义 | +| -------------- | ----------------------------------------- | +| `EINVAL` | 输入为空、重复或类型无效。 | +| `ENOENT` | 原生端所需文件不存在。 | +| `EEXIST` | 原生端输出路径已经存在。 | +| `EUNSUPPORTED` | 当前平台不支持所选 API。 | +| `EUNAVAILABLE` | 原生模块工作队列已经关闭。 | +| `EWEBASSEMBLY` | Worker、补丁校验或 WebAssembly 执行失败。 | +| `EUNSPECIFIED` | 未分类的原生异常。 | + +错误消息仅用于诊断,不是稳定的机器可读约定。恢复策略不同时应根据 `code` 分支。 + +## 并发与顺序 + +每个原生平台使用库内部的串行队列。每次 Web 调用会创建独立模块 Worker,并在完成后 +终止。不要假设不同 Web 调用会按提交顺序结束;对大输入应设置应用级并发限制。 + +## 补丁格式 + +四个操作都读写 `ENDSLEY/BSDIFF43` 补丁。以 `BSDIFF40` 开头的其他 bsdiff +变体不能互换。 diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md new file mode 100644 index 0000000..572ad65 --- /dev/null +++ b/docs/zh-CN/architecture.md @@ -0,0 +1,77 @@ +# 架构与补丁格式 + +库保留一套补丁实现,并通过三种运行时适配器暴露。 + +## 执行路径 + +```text +React Native JavaScript + -> 强类型公开 API + -> TurboModule 或旧桥接 + -> 平台自有串行工作队列 + -> JNI / Objective-C++ + -> 共用 bsdiff + bzip2 C 源码 + +React Native Web + -> 强类型公开 API + -> 模块 Web Worker + -> Emscripten MEMFS + -> 由同一套 bsdiff + bzip2 C 源码编译的 WebAssembly +``` + +Worker 边界让高开销二进制计算离开 JavaScript / UI 线程,但不会消除算法成本。 +调用方仍需设置符合产品场景的输入大小和时间限制。 + +## 补丁线格式 + +补丁以 24 字节头开始: + +| 字节 | 内容 | +| -------- | ------------------------------------ | +| `0..15` | ASCII magic `ENDSLEY/BSDIFF43` | +| `16..23` | 该格式字节序下的有符号 64 位目标大小 | +| `24..` | bzip2 压缩的控制、差分和附加数据 | + +Web 适配器进入 C patch 函数前会校验头和签名。原生与 Web 使用同一份已检入的 +bsdiff 和 bzip2 源码,从而保持跨平台兼容。 + +格式能标识补丁实现,但不标识预期基线或发布版本。分发补丁时,应用应在可信清单中 +携带基线和目标摘要。 + +## WebAssembly 打包 + +`scripts/build-web-wasm.sh` 使用 Emscripten 生成: + +- ES module 工厂; +- 单文件内嵌 WebAssembly payload; +- 可增长内存; +- MEMFS 以及 `FS` / `ccall` 运行时方法; +- 导出的 `bsDiffFile` 和 `bsPatchFile` 函数。 + +生成的 `web/bsdiffpatch.mjs` 随 npm 包发布,消费者无需安装 Emscripten。 + +## 内存模型 + +原生操作会把旧文件与目标文件读入进程内存。Web 调用先复制输入再传给 Worker, +之后从 MEMFS 复制结果,因此峰值内存可能达到输入或输出大小的数倍。 + +对于大更新,应在调用前执行应用级大小限制。完整文件无法安全放入内存时,应考虑 +服务端或流式更新策略。 + +## 职责边界 + +库负责补丁计算和平台调度;应用负责: + +- 文件选择、存储权限和临时文件清理; +- 补丁传输与缓存策略; +- 来源认证与密码学完整性校验; +- 并发、大小和时间限制; +- 还原结果验证和原子替换。 + +这些职责保留在补丁引擎外,便于应用复用已有文件系统和发布信任模型。 + +## 兼容规则 + +补丁兼容性由 magic 与实现共同决定,而不只是通用名称“bsdiff”。来自其他包的 +`BSDIFF40` 补丁不是受支持输入。跨 Android、iOS 与 Web 时,应使用本库成对生成 +和应用补丁。 diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md new file mode 100644 index 0000000..3032db5 --- /dev/null +++ b/docs/zh-CN/development.md @@ -0,0 +1,92 @@ +# 开发与验证 + +## 前置条件 + +- Node.js 18 或更高版本。 +- 通过仓库检入版本使用 Yarn 3.6.1。 +- Android 开发需要 Android Studio / JDK 17。 +- iOS 开发需要 Xcode 和 CocoaPods。 +- 只有重新生成已检入 WebAssembly bundle 时才需要 Emscripten。 + +## 安装 + +```sh +yarn install --immutable +``` + +根目录是库包,`example/` 是 React Native 消费者应用。 + +## 核心质量门禁 + +```sh +yarn prepare +yarn typecheck +yarn lint +yarn test --runInBand +``` + +## Web 门禁 + +```sh +yarn test:web +yarn test:web:browser +yarn test:web:metro +``` + +- `test:web` 检查 WebAssembly 往返和补丁 magic。 +- `test:web:browser` 在 Chrome 中运行公开 Worker API。 +- `test:web:metro` 证明 Metro 选择 `.web` 入口,而不是原生 TurboModule facade。 + +## 站点与文档 + +```sh +yarn site:build +yarn site:test +yarn site:test:browser +``` + +静态输出写入 `site-dist/`,由 GitHub Pages 工作流部署。构建脚本把 `docs/` 下的 +Markdown 渲染到站点。英文页位于 `docs/` 根部,中文镜像位于 `docs/zh-CN/`; +公开行为或 API 改动时应同步两种语言。 + +## 重新构建 WebAssembly + +修改 `cpp/` 后,激活 Emscripten 工具链并运行: + +```sh +yarn build:web +yarn test:web +yarn test:web:browser +``` + +将重新生成的 `web/bsdiffpatch.mjs` 与 C 源码改动一起提交。 + +## 原生验证 + +Android CI 构建两种架构模式,并在模拟器矩阵执行新架构设备级往返测试。iOS CI +使用示例 Gemfile 锁定的 CocoaPods 版本构建并测试旧架构和新架构配置。 + +本地示例命令见仓库 +[CONTRIBUTING.md](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/CONTRIBUTING.md)。 + +## 发布检查清单 + +1. 执行核心、Web 和站点门禁。 +2. 检查 `npm pack --dry-run --ignore-scripts`,确认包含 `web/`。 +3. 确认公开文档与导出的 TypeScript 声明一致。 +4. 确认中英文指南描述同一套公开行为。 +5. 运行 `yarn release` 创建版本、tag 和 GitHub Release;该命令不直接发布 npm。 +6. GitHub Release 发布后会触发 `npm-publish.yml`。工作流校验 tag 与 + `package.json` 版本一致,执行发布门禁,通过 npm Trusted Publishing 发布, + 并验证 provenance 证明。 +7. 对外发布前验证 npm 包和 GitHub Release。 + +npm 包的 Trusted Publisher 已按以下值配置完成: + +- Provider:GitHub Actions。 +- Organization or user:`JimmyDaddy`。 +- Repository:`react-native-bs-diff-patch`。 +- Workflow filename:`npm-publish.yml`。 +- Environment:留空。 + +正常发布无需再修改 npm 侧配置,工作流也不使用长期 npm token。 diff --git a/docs/zh-CN/getting-started.md b/docs/zh-CN/getting-started.md new file mode 100644 index 0000000..557cfdd --- /dev/null +++ b/docs/zh-CN/getting-started.md @@ -0,0 +1,126 @@ +# 快速开始 + +本指南会完成依赖安装、API 选择和一次完整的补丁生成与还原。 + +## 安装 + +```sh +npm install react-native-bs-diff-patch +``` + +新增或升级原生依赖后安装 iOS Pods: + +```sh +npx pod-install +``` + +React Native autolinking 会完成 Android 与 iOS 注册。安装后必须重新构建原生应用; +刷新 Metro 不会改变已经安装的应用二进制中包含的原生模块。 + +## 按运行时选择 API + +| 运行时 | 应使用 | 不应使用 | +| ------------ | --------------------------- | -------------- | +| Android、iOS | `diff` 和 `patch` | 二进制数据 API | +| Web | `diffBytes` 和 `patchBytes` | 文件路径 API | + +不可用的 API 族会以 `EUNSUPPORTED` 拒绝,便于发现入口解析到了错误平台。 + +## 原生文件流程 + +原生 API 使用绝对文件路径。本库不会选择存储目录,也不会管理文件生命周期;请使用 +应用已有的文件系统方案。 + +```ts +import { diff, patch } from 'react-native-bs-diff-patch'; + +type NativeRoundTripOptions = { + oldFilePath: string; + newFilePath: string; + cacheDirectory: string; +}; + +export async function nativeRoundTrip({ + oldFilePath, + newFilePath, + cacheDirectory, +}: NativeRoundTripOptions) { + const runId = Date.now(); + const patchPath = `${cacheDirectory}/release-${runId}.patch`; + const restoredPath = `${cacheDirectory}/release-${runId}.restored`; + + await diff(oldFilePath, newFilePath, patchPath); + await patch(oldFilePath, restoredPath, patchPath); + + return { patchPath, restoredPath }; +} +``` + +调用 `diff` 前: + +- `oldFilePath` 和 `newFilePath` 必须存在。 +- `patchPath` 必须不存在。 +- 三个路径必须非空且互不相同。 + +调用 `patch` 前: + +- `oldFilePath` 和 `patchPath` 必须存在。 +- `restoredPath` 必须不存在。 +- 三个路径必须非空且互不相同。 + +使用文件系统层提供的内容哈希或字节比较验证 `restoredPath` 与 `newFilePath` 一致。 +补丁和还原文件不再需要时应及时清理。 + +## Web 二进制流程 + +React Native Web 使用二进制值而不是文件路径: + +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; + +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 matches = + restoredData.length === newData.length && + restoredData.every((byte, index) => byte === newData[index]); + +if (!matches) { + throw new Error('Patch round trip did not reproduce the target data'); +} +``` + +输入在传给模块 Worker 前会被复制,因此调用方持有的缓冲区仍可继续使用。每次调用 +都会返回新的 `Uint8Array`。 + +## 使用浏览器文件 + +```ts +import { diffBytes } from 'react-native-bs-diff-patch'; + +export async function downloadPatch(oldFile: File, newFile: File) { + const patchData = await diffBytes(oldFile, newFile); + const url = URL.createObjectURL( + new Blob([patchData], { type: 'application/octet-stream' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'update.patch'; + link.click(); + URL.revokeObjectURL(url); +} +``` + +Web API 只能在客户端调用。在 SSR 阶段导入不会创建 Worker,但应等浏览器的 +`Worker` 可用后再执行二进制 API。 + +## 下一步 + +- 从[生产实践](/docs/zh-CN/recipes/)复制错误恢复模式。 +- 在 [API 参考](/docs/zh-CN/api-reference/)中查看全部签名和错误码。 +- 确认[平台与打包器支持](/docs/zh-CN/platform-support/)。 +- 尝试[在线 Playground](https://bs-dff-patch.corerobin.com/#playground)。 diff --git a/docs/zh-CN/platform-support.md b/docs/zh-CN/platform-support.md new file mode 100644 index 0000000..4ea881f --- /dev/null +++ b/docs/zh-CN/platform-support.md @@ -0,0 +1,66 @@ +# 平台支持 + +## 能力矩阵 + +| 能力 | Android | iOS | React Native Web | +| -------------------- | ------------------ | -------- | ---------------- | +| 文件路径 API | 支持 | 支持 | 不支持 | +| 二进制数据 API | 不支持 | 不支持 | 支持 | +| 旧桥接架构 | 支持 | 支持 | 不适用 | +| TurboModule / 新架构 | 支持 | 支持 | 不适用 | +| 后台执行 | 串行 executor | 串行队列 | 模块 Web Worker | +| 补丁格式 | `ENDSLEY/BSDIFF43` | 同左 | 同左 | + +示例应用持续验证 React Native 0.73。CI 也通过消费者 fixture 构建验证较新的新架构 +包 API,包括下面说明的 Android 包 API 分界。 + +## Android + +Android 根据 React Native 次版本选择新架构包实现: + +- React Native 0.73 使用兼容的 `TurboReactPackage` 源集。 +- React Native 0.74 及以上使用 `BaseReactPackage`。 +- 旧架构构建使用传统 `ReactPackage` 实现。 + +原生操作运行在模块自有的单线程 executor 上。项目内置 C 代码通过 CMake 构建, +并由 JNI 调用。 + +## iOS + +iOS autolinking 会为两种架构注册 `BsDiffPatch`。新架构 codegen 通过 +`modulesProvider` 映射模块;设置 `RCT_NEW_ARCH_ENABLED` 后,模块返回生成的 +TurboModule 实例。 + +操作在专用串行 dispatch queue 中运行,不占用主队列。 + +## React Native Web + +包提供两种 Web 入口机制: + +- `browser` 字段让标准浏览器感知型打包器选择 `web/index.mjs`。 +- `src/index.web.ts` 确保 Metro 平台解析器选择 Web API,即使 React Native 对 + `react-native` 包字段具有更高优先级。 + +浏览器需要支持: + +- WebAssembly; +- 模块 Web Worker; +- `ArrayBuffer` 与 TypedArray; +- 使用 `Blob` 输入时的 `Blob.arrayBuffer()`。 + +Webpack 与 Vite 能识别标准的 +`new Worker(new URL(..., import.meta.url), { type: 'module' })` 模式。Metro Web +配置需要在 Web serializer 中保留模块 Worker URL。 + +Web 入口面向浏览器,不是 Node.js 文件系统适配器;它不会在 Node.js 中提供原生 +文件路径 API。 + +## 服务端渲染 + +导入 Web 入口不会创建 Worker。在没有 `Worker` 的环境调用 `diffBytes` 或 +`patchBytes` 会以 `EUNSUPPORTED` 拒绝。仅在浏览器客户端代码中执行二进制 API。 + +## 补丁交换 + +补丁字节可以在 Android、iOS 与 Web 之间传递。文件访问、传输、存储、完整性验证 +和最终替换仍由应用负责。安全交换顺序见[生产实践](/docs/zh-CN/recipes/)。 diff --git a/docs/zh-CN/recipes.md b/docs/zh-CN/recipes.md new file mode 100644 index 0000000..4ef914f --- /dev/null +++ b/docs/zh-CN/recipes.md @@ -0,0 +1,90 @@ +# 生产实践 + +以下模式覆盖补丁引擎之外仍由应用负责的部分:唯一路径、清理、完整性和平台边界。 + +## 按错误码处理 + +错误消息只用于诊断,恢复决策应使用 `code`。 + +```ts +type PatchError = Error & { code?: string }; + +export function isPatchError(error: unknown): error is PatchError { + return error instanceof Error; +} + +try { + await patch(oldPath, outputPath, patchPath); +} catch (error) { + if (isPatchError(error) && error.code === 'EEXIST') { + // 清理已知的临时输出,或换一个唯一输出路径重试。 + } else if (isPatchError(error) && error.code === 'ENOENT') { + // 重新下载或定位所需的旧文件或补丁。 + } else { + throw error; + } +} +``` + +不要仅因为收到 `EEXIST` 就删除用户拥有的目标文件。只清理应用自己创建的临时路径。 + +## 跨运行时交换补丁 + +所有平台共用 `ENDSLEY/BSDIFF43`,因此合法流程可以跨越运行时: + +1. 在 Android 或 iOS 用 `diff`,或在 Web 用 `diffBytes` 生成补丁。 +2. 将补丁作为不透明二进制数据存储或传输,不进行文本转换。 +3. 向目标运行时提供该补丁所对应的精确基线文件。 +4. 使用目标运行时对应的 API 族应用补丁。 +5. 用可信目标哈希验证还原结果。 + +基线身份与补丁本身同样重要。将有效补丁应用到错误基线不是受支持的更新流程。 + +## 认证远程补丁 + +只有传输安全并不能证明补丁属于预期版本。建议分发带签名的清单,至少包含: + +- 基线版本或基线摘要; +- 补丁摘要和字节长度; +- 目标摘要和字节长度; +- 补丁格式标识; +- 发布标识和签名元数据。 + +应用补丁前验证清单和下载内容,替换业务数据前验证还原文件。签名与哈希保持在库外, +便于应用沿用已有信任模型。 + +## 原生端原子替换 + +将还原文件写到与最终目标相同存储区域中的唯一路径。完整性验证通过后,在文件系统层 +支持的情况下,通过原子重命名或替换完成切换。不要让 `patch` 直接覆盖活动文件; +其输出路径必须尚未使用。 + +## 限制资源使用 + +算法处理完整缓冲区,峰值内存可能是输入或输出的数倍。开始操作前应: + +- 拒绝超过产品验证上限的输入; +- 确认原生端临时输出所需的本地空间; +- 防止用户操作触发无限并发; +- 在外围流程适合的位置提供取消能力; +- 将超大更新的生成工作放到受控后端基础设施。 + +原生调用共用库内部串行队列;不同 Web 调用各自创建 Worker,因此应用应显式限制 +Web 并发。 + +## 下载 Web 补丁 + +创建对象 URL、触发下载,并在使用后释放 URL: + +```ts +const patchData = await diffBytes(oldFile, newFile); +const url = URL.createObjectURL(new Blob([patchData])); +const link = Object.assign(document.createElement('a'), { + href: url, + download: 'release.patch', +}); +link.click(); +URL.revokeObjectURL(url); +``` + +上传或存储时始终保持二进制字节。让任意补丁字节经过 UTF-8 字符串会破坏数据。 diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md new file mode 100644 index 0000000..8d919bc --- /dev/null +++ b/docs/zh-CN/troubleshooting.md @@ -0,0 +1,79 @@ +# 常见问题与排障 + +先看错误 `code`,再确认运行时和输入模型: + +| 现象 | 首要检查 | +| ----------------------------- | ------------------------------- | +| 找不到原生模块 | 重新构建并安装原生应用 | +| `ENOENT`、`EEXIST`、`EINVAL` | 在原生任务开始前检查路径状态 | +| `EUNSUPPORTED` | 确认选择了当前平台对应的 API 族 | +| Worker 或 `EWEBASSEMBLY` 失败 | 检查 Web 资源、CSP 和补丁 magic | +| 内存占用过高 | 设置输入大小和并发限制 | + +## `TurboModuleRegistry.getEnforcing(...): 'BsDiffPatch' could not be found` + +安装包后重新构建原生应用。Metro 刷新无法向已经安装的二进制添加原生模块。 + +- iOS:运行 `npx pod-install`,必要时清理 Xcode 构建并重新安装应用。 +- Android:停止应用,必要时清理旧 Gradle 输出并重新构建。 +- 确认 JavaScript 包和原生应用来自同一份依赖状态。 + +## `ENOENT` + +所需文件路径不存在。请确认: + +- 路径是绝对路径,并位于应用可访问存储中; +- `diff` 开始前旧文件和新文件已经存在; +- `patch` 开始前旧文件和补丁已经存在; +- 异步写入已经完成。 + +## `EEXIST` + +原生目标已经存在。本库不会静默覆盖补丁或还原文件。清理应用拥有的临时输出,或 +使用新的唯一路径。 + +## `EINVAL` + +路径可能为空或重复,Web 二进制输入也可能不是可接受类型。旧文件、目标输出和 +补丁路径必须互不相同。 + +## Web 上的 `EUNSUPPORTED` + +路径版 `diff` 和 `patch` 仅原生可用。React Native Web 应使用 `diffBytes` 和 +`patchBytes`。如果错误指出需要 Web Worker,请在浏览器客户端而不是 SSR 中调用。 + +## Worker 加载失败 + +确认打包器输出了模块 Worker 资源,并且服务器将 `.mjs` 作为 JavaScript 提供。 +严格 CSP 需要允许同源 Worker 和 WebAssembly 执行。 + +在浏览器网络面板中确认 `worker.mjs`、`operations.mjs` 和 `bsdiffpatch.mjs` +返回成功状态,而不是应用 HTML fallback。 + +## `EWEBASSEMBLY` 或补丁损坏 + +检查补丁前 16 字节。受支持补丁以 `ENDSLEY/BSDIFF43` 开头。截断补丁、 +`BSDIFF40` 补丁或其他二进制数据都会被拒绝。 + +## 内存占用过高 + +算法和适配器处理完整内存缓冲区。调用前添加大小限制,不要接受任意大的不可信文件。 +Web 虽在主线程外执行,仍会消耗当前标签页内存。 + +不同 Web 操作会创建不同 Worker。对重复用户操作进行防抖;大任务可能重叠时使用 +应用级队列。 + +## 还原结果不一致 + +确认补丁由当前使用的精确基线字节生成。补丁应保持为不透明二进制数据,避免字符串 +或 JSON 转换。应用前验证补丁摘要,替换业务数据前验证目标摘要。 + +## 提交诊断信息 + +创建 issue 时请提供: + +- React Native 和本库版本; +- 平台、架构模式和打包器; +- 被拒绝错误的 `code` 与消息; +- 不包含敏感数据的最小输入大小和路径状态; +- 问题能否在示例应用或在线 Playground 复现。 diff --git a/example/Gemfile b/example/Gemfile index 6a7d5c7..501b276 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -1,7 +1,9 @@ source 'https://rubygems.org' -# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby ">= 2.6.10" +# The ARM64 ffi package requires Ruby 3 or newer. +ruby '>= 3.0.0' -gem 'cocoapods', '~> 1.13' gem 'activesupport', '>= 6.1.7.3', '< 7.1.0' +gem 'cocoapods', '1.14.3' +gem 'ffi', '1.17.4' +gem 'xcpretty', '0.3.0' diff --git a/example/Gemfile.lock b/example/Gemfile.lock new file mode 100644 index 0000000..10af7ac --- /dev/null +++ b/example/Gemfile.lock @@ -0,0 +1,122 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.9) + activesupport (7.0.10) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + cocoapods (1.14.3) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.14.3) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.14.3) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.7) + drb (2.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.17.4) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.21.1) + logger (1.7.0) + minitest (5.26.1) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + netrc (0.11.0) + nkf (0.3.0) + public_suffix (4.0.7) + rexml (3.4.4) + rouge (2.0.7) + ruby-macho (2.5.1) + securerandom (0.3.2) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport (>= 6.1.7.3, < 7.1.0) + cocoapods (= 1.14.3) + ffi (= 1.17.4) + xcpretty (= 0.3.0) + +RUBY VERSION + ruby 3.0.0p0 + +BUNDLED WITH + 2.2.3 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 965fc06..ce2f32a 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -76,12 +76,14 @@ android { compileSdk rootProject.ext.compileSdkVersion namespace "com.bsdiffpatchexample" + testBuildType "release" defaultConfig { applicationId "com.bsdiffpatchexample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { debug { @@ -110,6 +112,10 @@ dependencies { implementation("com.facebook.react:react-android") implementation("com.facebook.react:flipper-integration") + androidTestImplementation("androidx.test:runner:1.5.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { diff --git a/example/android/app/src/androidTest/java/com/bsdiffpatchexample/NewArchitectureRuntimeTest.kt b/example/android/app/src/androidTest/java/com/bsdiffpatchexample/NewArchitectureRuntimeTest.kt new file mode 100644 index 0000000..eafb8f0 --- /dev/null +++ b/example/android/app/src/androidTest/java/com/bsdiffpatchexample/NewArchitectureRuntimeTest.kt @@ -0,0 +1,56 @@ +package com.bsdiffpatchexample + +import android.os.SystemClock +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class NewArchitectureRuntimeTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun diffAndPatchRoundTripThroughTurboModule() { + assertTrue( + "The runtime assertion must execute with React Native New Architecture enabled", + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + ) + + val deadline = SystemClock.uptimeMillis() + RUNTIME_TIMEOUT_MS + var lastFailure: Throwable? = null + + while (SystemClock.uptimeMillis() < deadline) { + try { + onView(withText("Runtime: success")).check(matches(isDisplayed())) + return + } catch (error: NoMatchingViewException) { + lastFailure = error + } catch (error: AssertionError) { + lastFailure = error + } + + SystemClock.sleep(RETRY_INTERVAL_MS) + } + + throw AssertionError( + "The React Native diff/patch round trip did not complete successfully", + lastFailure + ) + } + + private companion object { + const val RUNTIME_TIMEOUT_MS = 30_000L + const val RETRY_INTERVAL_MS = 250L + } +} diff --git a/example/ios/BsDiffPatchExample.xcodeproj/project.pbxproj b/example/ios/BsDiffPatchExample.xcodeproj/project.pbxproj index 9a50778..fcf4e99 100644 --- a/example/ios/BsDiffPatchExample.xcodeproj/project.pbxproj +++ b/example/ios/BsDiffPatchExample.xcodeproj/project.pbxproj @@ -156,11 +156,9 @@ buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "BsDiffPatchExampleTests" */; buildPhases = ( A55EABD7B0C7F3A422A6CC61 /* [CP] Check Pods Manifest.lock */, - 3E415EA71BEE3320F09CD0F3 /* [Expo] Configure project */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, - C59DA0FBD6956966B86A3779 /* [CP] Embed Pods Frameworks */, F6A41C54EA430FDDC6A6ED99 /* [CP] Copy Pods Resources */, ); buildRules = ( @@ -182,7 +180,6 @@ 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, ); buildRules = ( @@ -264,43 +261,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios relative | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; - }; - 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample/Pods-BsDiffPatchExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample/Pods-BsDiffPatchExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample/Pods-BsDiffPatchExample-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3E415EA71BEE3320F09CD0F3 /* [Expo] Configure project */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "[Expo] Configure project"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-BsDiffPatchExample-BsDiffPatchExampleTests/expo-configure-project.sh\"\n"; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; A55EABD7B0C7F3A422A6CC61 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -346,23 +307,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C59DA0FBD6956966B86A3779 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample-BsDiffPatchExampleTests/Pods-BsDiffPatchExample-BsDiffPatchExampleTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample-BsDiffPatchExampleTests/Pods-BsDiffPatchExample-BsDiffPatchExampleTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BsDiffPatchExample-BsDiffPatchExampleTests/Pods-BsDiffPatchExample-BsDiffPatchExampleTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/example/ios/BsDiffPatchExample/AppDelegate.mm b/example/ios/BsDiffPatchExample/AppDelegate.mm index a30ef8e..9da5cfd 100644 --- a/example/ios/BsDiffPatchExample/AppDelegate.mm +++ b/example/ios/BsDiffPatchExample/AppDelegate.mm @@ -28,4 +28,11 @@ - (NSURL *)getBundleURL #endif } +#ifdef RCT_NEW_ARCH_ENABLED +- (BOOL)bridgelessEnabled +{ + return YES; +} +#endif + @end diff --git a/example/ios/BsDiffPatchExampleTests/BsDiffPatchExampleTests.m b/example/ios/BsDiffPatchExampleTests/BsDiffPatchExampleTests.m index 602954d..ef26242 100644 --- a/example/ios/BsDiffPatchExampleTests/BsDiffPatchExampleTests.m +++ b/example/ios/BsDiffPatchExampleTests/BsDiffPatchExampleTests.m @@ -4,8 +4,10 @@ #import #import -#define TIMEOUT_SECONDS 600 -#define TEXT_TO_LOOK_FOR @"Welcome to React" +#define TIMEOUT_SECONDS 120 +#define RUNTIME_STATUS_ID @"runtime-status" +#define SUCCESS_STATUS @"Runtime: success" +#define ERROR_STATUS_PREFIX @"Runtime: error:" @interface BsDiffPatchExampleTests : XCTestCase @@ -26,11 +28,11 @@ - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test return NO; } -- (void)testRendersWelcomeScreen +- (void)testCompletesNativeDiffPatchRoundTrip { UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; - BOOL foundElement = NO; + __block NSString *runtimeStatus = nil; __block NSString *redboxError = nil; #ifdef DEBUG @@ -42,17 +44,22 @@ - (void)testRendersWelcomeScreen }); #endif - while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { + while ([date timeIntervalSinceNow] > 0 && !runtimeStatus && !redboxError) { [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - foundElement = [self findSubviewInView:vc.view - matching:^BOOL(UIView *view) { - if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { - return YES; - } - return NO; - }]; + [self findSubviewInView:vc.view + matching:^BOOL(UIView *view) { + if ([view.accessibilityIdentifier isEqualToString:RUNTIME_STATUS_ID]) { + NSString *status = view.accessibilityLabel; + if ([status isEqualToString:SUCCESS_STATUS] || + [status hasPrefix:ERROR_STATUS_PREFIX]) { + runtimeStatus = status; + return YES; + } + } + return NO; + }]; } #ifdef DEBUG @@ -60,7 +67,10 @@ - (void)testRendersWelcomeScreen #endif XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); - XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); + XCTAssertEqualObjects(runtimeStatus, + SUCCESS_STATUS, + @"Expected a successful native diff/patch round trip, got '%@'", + runtimeStatus); } @end diff --git a/example/ios/Podfile b/example/ios/Podfile index 56f8c84..e0e1dc7 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -8,17 +8,6 @@ require Pod::Executable.execute_command('node', ['-p', platform :ios, min_ios_version_supported prepare_react_native_project! -# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. -# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded -# -# To fix this you can also exclude `react-native-flipper` using a `react-native.config.js` -# ```js -# module.exports = { -# dependencies: { -# ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}), -# ``` -flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled - linkage = ENV['USE_FRAMEWORKS'] if linkage != nil Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green @@ -26,22 +15,12 @@ if linkage != nil end target 'BsDiffPatchExample' do - post_integrate do |installer| - begin - expo_patch_react_imports!(installer) - rescue => e - Pod::UI.warn e - end - end config = use_native_modules! use_react_native!( :path => config[:reactNativePath], - # Enables Flipper. - # - # Note that if you have use_frameworks! enabled, Flipper will not work and - # you should disable the next line. - :flipper_configuration => flipper_config, + # Flipper is unnecessary in CI and is incompatible with current Xcode releases. + :flipper_configuration => FlipperConfiguration.disabled, # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/..", :hermes_enabled => false diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 8a5aef9..5c27ce0 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,5 @@ PODS: - boost (1.83.0) - - CocoaAsyncSocket (7.6.5) - DoubleConversion (1.1.6) - FBLazyVector (0.73.2) - FBReactNativeSpec (0.73.2): @@ -10,66 +9,8 @@ PODS: - React-Core (= 0.73.2) - React-jsi (= 0.73.2) - ReactCommon/turbomodule/core (= 0.73.2) - - Flipper (0.201.0): - - Flipper-Folly (~> 2.6) - - Flipper-Boost-iOSX (1.76.0.1.11) - - Flipper-DoubleConversion (3.2.0.1) - - Flipper-Fmt (7.1.7) - - Flipper-Folly (2.6.10): - - Flipper-Boost-iOSX - - Flipper-DoubleConversion - - Flipper-Fmt (= 7.1.7) - - Flipper-Glog - - libevent (~> 2.1.12) - - OpenSSL-Universal (= 1.1.1100) - - Flipper-Glog (0.5.0.5) - - Flipper-PeerTalk (0.0.4) - - FlipperKit (0.201.0): - - FlipperKit/Core (= 0.201.0) - - FlipperKit/Core (0.201.0): - - Flipper (~> 0.201.0) - - FlipperKit/CppBridge - - FlipperKit/FBCxxFollyDynamicConvert - - FlipperKit/FBDefines - - FlipperKit/FKPortForwarding - - SocketRocket (~> 0.6.0) - - FlipperKit/CppBridge (0.201.0): - - Flipper (~> 0.201.0) - - FlipperKit/FBCxxFollyDynamicConvert (0.201.0): - - Flipper-Folly (~> 2.6) - - FlipperKit/FBDefines (0.201.0) - - FlipperKit/FKPortForwarding (0.201.0): - - CocoaAsyncSocket (~> 7.6) - - Flipper-PeerTalk (~> 0.0.4) - - FlipperKit/FlipperKitHighlightOverlay (0.201.0) - - FlipperKit/FlipperKitLayoutHelpers (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitHighlightOverlay - - FlipperKit/FlipperKitLayoutTextSearchable - - FlipperKit/FlipperKitLayoutIOSDescriptors (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitHighlightOverlay - - FlipperKit/FlipperKitLayoutHelpers - - FlipperKit/FlipperKitLayoutPlugin (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitHighlightOverlay - - FlipperKit/FlipperKitLayoutHelpers - - FlipperKit/FlipperKitLayoutIOSDescriptors - - FlipperKit/FlipperKitLayoutTextSearchable - - FlipperKit/FlipperKitLayoutTextSearchable (0.201.0) - - FlipperKit/FlipperKitNetworkPlugin (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitReactPlugin (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitUserDefaultsPlugin (0.201.0): - - FlipperKit/Core - - FlipperKit/SKIOSNetworkPlugin (0.201.0): - - FlipperKit/Core - - FlipperKit/FlipperKitNetworkPlugin - fmt (6.2.1) - glog (0.3.5) - - libevent (2.1.12) - - OpenSSL-Universal (1.1.1100) - RCT-Folly (2022.05.16.00): - boost - DoubleConversion @@ -911,7 +852,7 @@ PODS: - React-Mapbuffer (0.73.2): - glog - React-debug - - react-native-bs-diff-patch (0.1.0): + - react-native-bs-diff-patch (0.0.2): - glog - RCT-Folly (= 2022.05.16.00) - React-Core @@ -1088,28 +1029,7 @@ DEPENDENCIES: - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`) - - Flipper (= 0.201.0) - - Flipper-Boost-iOSX (= 1.76.0.1.11) - - Flipper-DoubleConversion (= 3.2.0.1) - - Flipper-Fmt (= 7.1.7) - - Flipper-Folly (= 2.6.10) - - Flipper-Glog (= 0.5.0.5) - - Flipper-PeerTalk (= 0.0.4) - - FlipperKit (= 0.201.0) - - FlipperKit/Core (= 0.201.0) - - FlipperKit/CppBridge (= 0.201.0) - - FlipperKit/FBCxxFollyDynamicConvert (= 0.201.0) - - FlipperKit/FBDefines (= 0.201.0) - - FlipperKit/FKPortForwarding (= 0.201.0) - - FlipperKit/FlipperKitHighlightOverlay (= 0.201.0) - - FlipperKit/FlipperKitLayoutPlugin (= 0.201.0) - - FlipperKit/FlipperKitLayoutTextSearchable (= 0.201.0) - - FlipperKit/FlipperKitNetworkPlugin (= 0.201.0) - - FlipperKit/FlipperKitReactPlugin (= 0.201.0) - - FlipperKit/FlipperKitUserDefaultsPlugin (= 0.201.0) - - FlipperKit/SKIOSNetworkPlugin (= 0.201.0) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - - OpenSSL-Universal (= 1.1.1100) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) @@ -1118,7 +1038,6 @@ DEPENDENCIES: - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) - React-Codegen (from `build/generated/ios`) - React-Core (from `../node_modules/react-native/`) - - React-Core/DevSupport (from `../node_modules/react-native/`) - React-Core/RCTWebSocket (from `../node_modules/react-native/`) - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) @@ -1160,18 +1079,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - - CocoaAsyncSocket - - Flipper - - Flipper-Boost-iOSX - - Flipper-DoubleConversion - - Flipper-Fmt - - Flipper-Folly - - Flipper-Glog - - Flipper-PeerTalk - - FlipperKit - fmt - - libevent - - OpenSSL-Universal - SocketRocket EXTERNAL SOURCES: @@ -1276,68 +1184,57 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: d3f49c53809116a5d38da093a8aa78bf551aed09 - CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4 FBReactNativeSpec: 86de768f89901ef6ed3207cd686362189d64ac88 - Flipper: c7a0093234c4bdd456e363f2f19b2e4b27652d44 - Flipper-Boost-iOSX: fd1e2b8cbef7e662a122412d7ac5f5bea715403c - Flipper-DoubleConversion: 2dc99b02f658daf147069aad9dbd29d8feb06d30 - Flipper-Fmt: 60cbdd92fc254826e61d669a5d87ef7015396a9b - Flipper-Folly: 584845625005ff068a6ebf41f857f468decd26b3 - Flipper-Glog: 70c50ce58ddaf67dc35180db05f191692570f446 - Flipper-PeerTalk: 116d8f857dc6ef55c7a5a75ea3ceaafe878aadc9 - FlipperKit: 37525a5d056ef9b93d1578e04bc3ea1de940094f fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 - libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c RCT-Folly: 7169b2b1c44399c76a47b5deaaba715eeeb476c0 RCTRequired: 9b1e7e262745fb671e33c51c1078d093bd30e322 RCTTypeSafety: a759e3b086eccf3e2cbf2493d22f28e082f958e6 React: 805f5dd55bbdb92c36b4914c64aaae4c97d358dc React-callinvoker: 6a697867607c990c2c2c085296ee32cfb5e47c01 - React-Codegen: 39377d8c90c3fc0792753c9af53b788abfe5850b - React-Core: 943d6097aaf381b1e7c7e105eecd5a27b51c4e17 - React-CoreModules: 710e7c557a1a8180bd1645f5b4bf79f4bd3f5417 - React-cxxreact: 0f0b3933c36dfe4ed10638a33398533f90ab78d3 + React-Codegen: ea685412377744d2e2108adb131fce2f040ddd14 + React-Core: d9a7e296b2e7b57f27d81b62a7d293d06527268a + React-CoreModules: 399f72f0892befe0fde5d415703c0b9b356762e7 + React-cxxreact: beb7cb8adddd4b25d4262f55927dcd1d3577e99d React-debug: f1637bce73342b2f6eee4982508fdfb088667a87 - React-Fabric: ba7d74992ed878fdbf91f8b49eb725b310786980 - React-FabricImage: e7457fb89db50cb1b51d0546b5ff002b91026efe - React-graphics: dd5af9d8b1b45171fd6933e19fed522f373bcb10 - React-ImageManager: c5b7db131eff71443d7f3a8d686fd841d18befd3 + React-Fabric: cb2eba1fd764229f9a5737fa7b339695d4949848 + React-FabricImage: b86bf7e1c6560bf82ade361dac6f3281e68453ee + React-graphics: 87ba141b72379824c7835224c1a87993683151a8 + React-ImageManager: 1bc92d558d4d5de07c6c1a7244d33ad2a872728e React-jsc: 94234736a90ea29f017f2ee76e5f358a6ba076a9 - React-jserrorhandler: 97a6a12e2344c3c4fdd7ba1edefb005215c732f8 - React-jsi: 0cd661b6ea862c104706311f8265050ee3ecf5e4 - React-jsiexecutor: 94f6026bc4054b413f0ac5e210691c2916d99d1b + React-jserrorhandler: 0b1476485be6d79f09a8f0548c355b1cc14e8f21 + React-jsi: dbfd3bab7712367d4c2aced271d794dde76f0d68 + React-jsiexecutor: 368e562638c31174479c00434d37fd67b761c15b React-jsinspector: 03644c063fc3621c9a4e8bf263a8150909129618 - React-logger: 66b168e2b2bee57bd8ce9e69f739d805732a5570 - React-Mapbuffer: 9ee041e1d7be96da6d76a251f92e72b711c651d6 - react-native-bs-diff-patch: b576bc4b40a2fe83e556da494214e6381ca4d119 + React-logger: b42a493ae72922d8d6a497038359240ffd457119 + React-Mapbuffer: 2cd1af67c3754dfb934948448125f0e051586db7 + react-native-bs-diff-patch: c082b14fa87d60df5e29d33ac8a3edb1d6d4d971 React-nativeconfig: d753fbbc8cecc8ae413d615599ac378bbf6999bb - React-NativeModulesApple: 22c25a1baa4b0d0d4845dad2578fc017b0805589 + React-NativeModulesApple: 0c22e17930a2de06bbd4d49a149351a5151283dc React-perflogger: 29efe63b7ef5fbaaa50ef6eaa92482f98a24b97e React-RCTActionSheet: 69134c62aefd362027b20da01cd5d14ffd39db3f - React-RCTAnimation: 3b5a57087c7a5e727855b803d643ac1d445488f5 - React-RCTAppDelegate: 842870b97f47de7255908ba1ca8786aef877b0b8 - React-RCTBlob: 1fa011b5860c9a70802fab986ad334b458387b7a - React-RCTFabric: c8f86a85501d70c8a77d71f22273e325ffb63fa0 - React-RCTImage: 27b27f4663df9e776d0549ed2f3536213e793f1b - React-RCTLinking: 962880ce9d0e2ea83fd182953538fc4ed757d4da - React-RCTNetwork: 73a756b44d4ad584bae13a5f1484e3ce12accac8 - React-RCTSettings: 6d7f8d807f05de3d01cfb182d14e5f400716faac - React-RCTText: 73006e95ca359595c2510c1c0114027c85a6ddd3 - React-RCTVibration: 599f427f9cbdd9c4bf38959ca020e8fef0717211 - React-rendererdebug: f2946e0a1c3b906e71555a7c4a39aa6a6c0e639b + React-RCTAnimation: ed774e28e707ce47b1e2dc6aa7f8f3267b815061 + React-RCTAppDelegate: b3312577f20a1c3aaf58ff6d561f3119c74b95b4 + React-RCTBlob: 8c173ce722daff128efb38f29a390984800f9302 + React-RCTFabric: a0a76ccfa863b02382cb74e04450e9f69cd1cf49 + React-RCTImage: 1f75f5d0539b381f70981386b1459fbb2c21eb9b + React-RCTLinking: febd566d57a9cb05a02f39771ccc8c20f8432e89 + React-RCTNetwork: d427de729372fd50d7cb601db64d0fcd9ea9a514 + React-RCTSettings: 73594c6c8c334c7d958cf98ac72335f3e4df9bf5 + React-RCTText: f1079c24f45cec6ddb6363c12ad87f9a940b2ddb + React-RCTVibration: 62420b57a47482d1b88dde64ba88d333f2625aab + React-rendererdebug: a474ec4cdfed75211dce6c3828de8391cc5c4280 React-rncore: 74030de0ffef7b1a3fb77941168624534cc9ae7f React-runtimeexecutor: 2d1f64f58193f00a3ad71d3f89c2bfbfe11cf5a5 - React-runtimescheduler: 6517c0cdfae3ea29b599759e069ae97746163248 - React-utils: f5bc61e7ea3325c0732ae2d755f4441940163b85 - ReactCommon: a42e89b49d88c3890dfb6fd98a33ac80d555be19 - RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 + React-runtimescheduler: 60e37202d804ff618723c9d01d194fed91a44265 + React-utils: dd1e04ea7105dc58637939b50f1a74a5dd634ec4 + ReactCommon: 95988d0d48646507f3e952a02cab66e8d7f08872 + RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Yoga: 13c8ef87792450193e117976337b8527b49e8c03 -PODFILE CHECKSUM: 0106c40264c7523f9e5d7d93667c60a889d6711f +PODFILE CHECKSUM: 4abad8f5d0039c55400fe3dc57af4b12719ceb23 COCOAPODS: 1.14.3 diff --git a/example/src/App.tsx b/example/src/App.tsx index 9184603..bd379b5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -12,47 +12,58 @@ export default function App() { const [textLength, setTextLength] = React.useState(); const [patchFileUri, setPatchFileUri] = React.useState(); + const [runtimeStatus, setRuntimeStatus] = React.useState('running'); React.useEffect(() => { - FS.writeFile( - FS.DocumentDirectoryPath + '/test.txt', - new Array(10000).fill('Hello World').join(' | ') - ) - .then(async () => { - try { - await FS.writeFile( - FS.DocumentDirectoryPath + '/test1.txt', - new Array(10000).fill('Hello World 1').join(' | ') - ); + const oldContent = new Array(1000).fill('Hello World').join(' | '); + const expectedContent = new Array(1000).fill('Hello World 1').join(' | '); + let cancelled = false; - let patchFileExists = await FS.exists(patchFile); + async function runRoundTrip() { + try { + await FS.writeFile(oldFile, oldContent); + await FS.writeFile(newFile, expectedContent); - if (patchFileExists) { - await FS.unlink(patchFile); - } - console.log('write done'); - await diff(oldFile, newFile, patchFile); - console.log('diff done', patchFile, oldFile, newFile); - patchFileExists = await FS.exists(patchFile); - console.log('start patch', patchFileExists); - const patchFileInfoInner = await FS.stat(patchFile); - setPatchFileUri(patchFileInfoInner.path); - const newFile1InfoExists = await FS.exists(newFile1); - if (newFile1InfoExists) { - await FS.unlink(newFile1); - } - await patch(oldFile, newFile1, patchFile); - console.log('patch done'); - const t = await FS.readFile(newFile1); - setTextLength(t.length); - } catch (error) { - console.log(error); + if (await FS.exists(patchFile)) { + await FS.unlink(patchFile); } - }) - .catch((e) => { - console.log(e); - }); + if (await FS.exists(newFile1)) { + await FS.unlink(newFile1); + } + + const diffResult = await diff(oldFile, newFile, patchFile); + const patchFileInfo = await FS.stat(patchFile); + const patchResult = await patch(oldFile, newFile1, patchFile); + const patchedContent = await FS.readFile(newFile1); + + if ( + diffResult !== 0 || + patchResult !== 0 || + patchedContent !== expectedContent + ) { + throw new Error( + 'diff/patch round trip produced an unexpected result' + ); + } + + if (!cancelled) { + setPatchFileUri(patchFileInfo.path); + setTextLength(patchedContent.length); + setRuntimeStatus('success'); + } + } catch (error) { + if (!cancelled) { + const message = + error instanceof Error ? error.message : String(error); + setRuntimeStatus(`error: ${message}`); + } + } + } + + runRoundTrip(); + return () => { + cancelled = true; FS.exists(oldFile).then((exists) => { if (exists) { FS.unlink(oldFile); @@ -70,6 +81,7 @@ export default function App() { Text: {textLength} Patch: {patchFileUri} + Runtime: {runtimeStatus} ); } diff --git a/ios/BsDiffPatch.mm b/ios/BsDiffPatch.mm index cc6886c..3649814 100644 --- a/ios/BsDiffPatch.mm +++ b/ios/BsDiffPatch.mm @@ -1,8 +1,28 @@ #import "BsDiffPatch.h" +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#endif + @implementation BsDiffPatch RCT_EXPORT_MODULE() ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (dispatch_queue_t)methodQueue +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.jimmydaddy.bsdiffpatch.worker", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + // Example method // See // https://reactnative.dev/docs/native-modules-ios RCT_EXPORT_METHOD(patch:(NSString*) oldFile @@ -89,4 +109,12 @@ @implementation BsDiffPatch resolve(result); } +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif + @end diff --git a/package.json b/package.json index b7c1d17..29a0edd 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "react-native-bs-diff-patch", "version": "0.0.2", - "description": "rn bs diff patch ", + "description": "Binary diff and patch support for React Native and React Native Web", "main": "lib/commonjs/index", "module": "lib/module/index", + "browser": "web/index.mjs", "types": "lib/typescript/src/index.d.ts", "react-native": "src/index", "source": "src/index", @@ -13,6 +14,8 @@ "android", "ios", "cpp", + "web", + "scripts/build-web-wasm.sh", "*.podspec", "!ios/build", "!android/build", @@ -20,6 +23,9 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!lib/commonjs/index.web.js*", + "!lib/module/index.web.js*", + "!lib/typescript/src/index.web.d.ts*", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", @@ -28,14 +34,24 @@ "scripts": { "example": "yarn workspace react-native-bs-diff-patch-example", "test": "jest", + "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", + "site:build": "node scripts/build-site.mjs", + "site:test": "node scripts/test-site.mjs", + "site:test:browser": "node scripts/test-site-browser.mjs", + "site:preview": "python3 -m http.server 4173 --bind 127.0.0.1 --directory site-dist", "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", + "build:web": "bash scripts/build-web-wasm.sh", "release": "release-it" }, "keywords": [ "react-native", + "react-native-web", + "webassembly", "ios", "android" ], @@ -68,6 +84,7 @@ "jest": "^28.1.1", "pod-install": "^0.1.0", "prettier": "^2.0.5", + "puppeteer-core": "^22.15.0", "react": "18.2.0", "react-native": "0.73.2", "react-native-builder-bob": "^0.20.0", @@ -107,7 +124,7 @@ "tagName": "v${version}" }, "npm": { - "publish": true + "publish": false }, "github": { "release": true @@ -138,6 +155,7 @@ } }, "eslintIgnore": [ + "build/", "node_modules/", "lib/" ], @@ -161,5 +179,18 @@ } ] ] + }, + "codegenConfig": { + "name": "RNBsDiffPatchSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.jimmydaddy.bsdiffpatch" + }, + "ios": { + "modulesProvider": { + "BsDiffPatch": "BsDiffPatch" + } + } } } diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 0000000..5e49481 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,519 @@ +import { cp, mkdir, readFile, rm, 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 siteDirectory = path.join(repositoryDirectory, 'site'); +const outputDirectory = path.join(repositoryDirectory, 'site-dist'); +const docsDirectory = path.join(repositoryDirectory, 'docs'); + +const pages = [ + { + slug: 'getting-started', + title: 'Getting started', + description: + 'Install the package and complete your first native or Web patch round trip.', + file: 'getting-started.md', + }, + { + slug: 'api-reference', + title: 'API reference', + description: + 'Signatures, accepted inputs, return values, errors, and concurrency behavior.', + file: 'api-reference.md', + }, + { + slug: 'recipes', + title: 'Production recipes', + description: + 'Integrity checks, temporary files, downloads, resource limits, and cross-runtime workflows.', + file: 'recipes.md', + }, + { + slug: 'platform-support', + title: 'Platform support', + description: + 'Android, iOS, New Architecture, React Native Web, Metro, and browser requirements.', + file: 'platform-support.md', + }, + { + slug: 'architecture', + title: 'Architecture', + description: + 'Execution boundaries, the shared C core, WebAssembly packaging, and patch compatibility.', + file: 'architecture.md', + }, + { + slug: 'troubleshooting', + title: 'Troubleshooting', + description: + 'Resolve native registration, filesystem, Worker, WebAssembly, and patch-format failures.', + file: 'troubleshooting.md', + }, + { + slug: 'development', + title: 'Development', + description: + 'Repository setup, native and Web gates, site checks, WebAssembly builds, and releases.', + file: 'development.md', + }, +]; + +const chinesePages = [ + { + slug: 'getting-started', + title: '快速开始', + description: '安装依赖,并完成第一次原生端或 Web 补丁往返。', + file: 'getting-started.md', + }, + { + slug: 'api-reference', + title: 'API 参考', + description: '函数签名、可接受输入、返回值、错误码和并发行为。', + file: 'api-reference.md', + }, + { + slug: 'recipes', + title: '生产实践', + description: '补丁完整性、临时文件、下载、资源限制和跨运行时流程。', + file: 'recipes.md', + }, + { + slug: 'platform-support', + title: '平台支持', + description: 'Android、iOS、新架构、React Native Web 与打包器要求。', + file: 'platform-support.md', + }, + { + slug: 'architecture', + title: '架构', + description: '执行边界、共用 C 核心、WebAssembly 打包与补丁兼容性。', + file: 'architecture.md', + }, + { + slug: 'troubleshooting', + title: '常见问题与排障', + description: '处理原生注册、文件系统、Worker、WebAssembly 与格式错误。', + file: 'troubleshooting.md', + }, + { + slug: 'development', + title: '开发与验证', + description: '仓库配置、原生与 Web 门禁、站点测试、WASM 构建和发布检查。', + file: 'development.md', + }, +]; + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function normalizeDocumentationLink(href) { + const markdownMatch = href.match(/^\.\/([a-z-]+)\.md(#[\w-]+)?$/); + if (markdownMatch) { + return `/docs/${markdownMatch[1]}/${markdownMatch[2] || ''}`; + } + if (href === '../CONTRIBUTING.md') { + return 'https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/CONTRIBUTING.md'; + } + return href; +} + +function renderInline(value) { + const codeTokens = []; + let rendered = escapeHtml(value).replace(/`([^`]+)`/g, (_, code) => { + const token = `@@CODE${codeTokens.length}@@`; + codeTokens.push(`${code}`); + return token; + }); + + rendered = rendered + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => { + const normalized = escapeHtml(normalizeDocumentationLink(href)); + const external = normalized.startsWith('http') ? ' rel="noreferrer"' : ''; + return `${label}`; + }) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + + for (const [index, code] of codeTokens.entries()) { + rendered = rendered.replace(`@@CODE${index}@@`, code); + } + return rendered; +} + +function renderTable(lines) { + const cells = (line) => + line + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); + const header = cells(lines[0]); + const rows = lines.slice(2).map(cells); + return `
${header + .map((cell) => ``) + .join('')}${rows + .map( + (row) => + `${row + .map((cell) => ``) + .join('')}` + ) + .join('')}
${renderInline(cell)}
${renderInline(cell)}
`; +} + +function renderMarkdown(markdown) { + const lines = markdown.replaceAll('\r\n', '\n').split('\n'); + const output = []; + let paragraph = []; + let listType; + let codeLanguage; + let codeLines = []; + + const flushParagraph = () => { + if (paragraph.length) { + output.push(`

${renderInline(paragraph.join(' '))}

`); + paragraph = []; + } + }; + const closeList = () => { + if (listType) { + output.push(``); + listType = undefined; + } + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + + if (codeLanguage !== undefined) { + if (line.startsWith('```')) { + output.push( + `
${escapeHtml(
+            codeLines.join('\n')
+          )}
` + ); + codeLanguage = undefined; + codeLines = []; + } else { + codeLines.push(line); + } + continue; + } + + if (line.startsWith('```')) { + flushParagraph(); + closeList(); + codeLanguage = line.slice(3).trim(); + continue; + } + + const nextLine = lines[index + 1] || ''; + if (line.includes('|') && /^\s*\|?\s*:?-+/.test(nextLine)) { + flushParagraph(); + closeList(); + const tableLines = [line, nextLine]; + index += 2; + while (index < lines.length && lines[index].includes('|')) { + tableLines.push(lines[index]); + index += 1; + } + index -= 1; + output.push(renderTable(tableLines)); + continue; + } + + const heading = line.match(/^(#{1,4})\s+(.+)$/); + if (heading) { + flushParagraph(); + closeList(); + const level = heading[1].length; + if (level > 1) { + const text = heading[2]; + const id = text + .normalize('NFKC') + .toLowerCase() + .replace(/`/g, '') + .replace(/[^\p{Letter}\p{Number}]+/gu, '-') + .replace(/^-|-$/g, ''); + output.push(`${renderInline(text)}`); + } + continue; + } + + const unordered = line.match(/^[-*]\s+(.+)$/); + const ordered = line.match(/^\d+\.\s+(.+)$/); + if (unordered || ordered) { + flushParagraph(); + const nextListType = unordered ? 'ul' : 'ol'; + if (listType !== nextListType) { + closeList(); + listType = nextListType; + output.push(`<${listType}>`); + } + output.push(`
  • ${renderInline((unordered || ordered)[1])}
  • `); + continue; + } + + if (!line.trim()) { + flushParagraph(); + closeList(); + continue; + } + + paragraph.push(line.trim()); + } + + flushParagraph(); + closeList(); + return output.join('\n'); +} + +const englishUi = { + language: 'en', + basePath: '/docs', + alternateLanguage: 'zh-CN', + alternateBasePath: '/docs/zh-CN', + alternateLabel: '中文', + homeTitle: 'Documentation home', + skipLabel: 'Skip to documentation', + primaryNavigationLabel: 'Primary navigation', + playgroundLabel: 'Playground', + docsLabel: 'Docs', + menuLabel: 'Menu', + closeMenuLabel: 'Close', + sidebarLabel: 'Documentation navigation', + sidebarTitle: 'Documentation', + breadcrumbLabel: 'Docs', + guideLabel: 'Guide', + footerText: 'MIT licensed. Built for React Native runtimes.', + footerNavigationLabel: 'Footer navigation', + homeLabel: 'Home', +}; + +const chineseUi = { + language: 'zh-CN', + basePath: '/docs/zh-CN', + alternateLanguage: 'en', + alternateBasePath: '/docs', + alternateLabel: 'English', + homeTitle: '文档首页', + skipLabel: '跳到文档正文', + primaryNavigationLabel: '主导航', + playgroundLabel: 'Playground', + docsLabel: '中文文档', + menuLabel: '菜单', + closeMenuLabel: '关闭', + sidebarLabel: '文档导航', + sidebarTitle: '中文文档', + breadcrumbLabel: '文档', + guideLabel: '指南', + footerText: 'MIT 许可,为 React Native 多运行时构建。', + footerNavigationLabel: '页脚导航', + homeLabel: '首页', +}; + +function navigation(items, currentSlug, ui) { + return [ + { slug: '', title: ui.homeTitle }, + ...items.map(({ slug, title }) => ({ slug, title })), + ] + .map(({ slug, title }) => { + const href = slug ? `${ui.basePath}/${slug}/` : `${ui.basePath}/`; + const current = slug === currentSlug ? ' aria-current="page"' : ''; + return `${escapeHtml(title)}`; + }) + .join('\n'); +} + +function documentationLayout({ slug, title, description, content, items, ui }) { + const canonical = slug ? `${ui.basePath}/${slug}/` : `${ui.basePath}/`; + const alternate = slug + ? `${ui.alternateBasePath}/${slug}/` + : `${ui.alternateBasePath}/`; + return ` + + + + + + + + + + + + + ${escapeHtml(title)} — react-native-bs-diff-patch + + + + + +
    + +
    +
    +

    ${ui.breadcrumbLabel} / ${escapeHtml( + title + )}

    +

    ${escapeHtml(title)}

    +

    ${escapeHtml(description)}

    +
    +
    ${content}
    +
    +
    + + + +`; +} + +function docsHomeContent(items, ui) { + return `
    ${items + .map( + ({ slug, title, description }, index) => ` + ${String(index + 1).padStart(2, '0')} / ${ui.guideLabel} +

    ${escapeHtml(title)}

    ${escapeHtml(description)}

    +
    ` + ) + .join('')}
    `; +} + +await rm(outputDirectory, { recursive: true, force: true }); +await mkdir(outputDirectory, { recursive: true }); +await cp( + path.join(siteDirectory, 'assets'), + path.join(outputDirectory, 'assets'), + { + recursive: true, + } +); + +for (const filename of [ + 'index.html', + '404.html', + 'CNAME', + 'robots.txt', + 'sitemap.xml', +]) { + await cp( + path.join(siteDirectory, filename), + path.join(outputDirectory, filename) + ); +} +await writeFile(path.join(outputDirectory, '.nojekyll'), ''); + +await cp( + path.join(repositoryDirectory, 'web'), + path.join(outputDirectory, 'web'), + { + recursive: true, + } +); + +const docsOutputDirectory = path.join(outputDirectory, 'docs'); +await mkdir(docsOutputDirectory, { recursive: true }); +await writeFile( + path.join(docsOutputDirectory, 'index.html'), + documentationLayout({ + slug: '', + title: 'Documentation', + description: + 'Install, integrate, operate, and troubleshoot compatible binary patches across every supported React Native runtime.', + content: docsHomeContent(pages, englishUi), + items: pages, + ui: englishUi, + }) +); + +for (const page of pages) { + const markdown = await readFile(path.join(docsDirectory, page.file), 'utf8'); + const pageOutputDirectory = path.join(docsOutputDirectory, page.slug); + await mkdir(pageOutputDirectory, { recursive: true }); + await writeFile( + path.join(pageOutputDirectory, 'index.html'), + documentationLayout({ + ...page, + content: renderMarkdown(markdown), + items: pages, + ui: englishUi, + }) + ); +} + +const chineseDocsDirectory = path.join(docsDirectory, 'zh-CN'); +const chineseOutputDirectory = path.join(docsOutputDirectory, 'zh-CN'); +await mkdir(chineseOutputDirectory, { recursive: true }); +await writeFile( + path.join(chineseOutputDirectory, 'index.html'), + documentationLayout({ + slug: '', + title: '中文文档', + description: + '安装、集成、运行并排查 Android、iOS 与 React Native Web 上的兼容二进制补丁。', + content: docsHomeContent(chinesePages, chineseUi), + items: chinesePages, + ui: chineseUi, + }) +); + +for (const page of chinesePages) { + const markdown = await readFile( + path.join(chineseDocsDirectory, page.file), + 'utf8' + ); + const pageOutputDirectory = path.join(chineseOutputDirectory, page.slug); + await mkdir(pageOutputDirectory, { recursive: true }); + await writeFile( + path.join(pageOutputDirectory, 'index.html'), + documentationLayout({ + ...page, + content: renderMarkdown(markdown), + items: chinesePages, + ui: chineseUi, + }) + ); +} + +console.log(`Built site at ${outputDirectory}`); diff --git a/scripts/build-web-wasm.sh b/scripts/build-web-wasm.sh new file mode 100644 index 0000000..b238951 --- /dev/null +++ b/scripts/build-web-wasm.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "${script_dir}/.." && pwd)" +emcc_bin="${EMCC:-emcc}" + +if ! command -v "${emcc_bin}" >/dev/null 2>&1; then + echo "Emscripten compiler not found. Set EMCC or add emcc to PATH." >&2 + exit 1 +fi + +"${emcc_bin}" \ + "${repo_dir}/cpp/bsdiff.c" \ + "${repo_dir}/cpp/bspatch.c" \ + "${repo_dir}/cpp/bzlib/blocksort.c" \ + "${repo_dir}/cpp/bzlib/bzlib.c" \ + "${repo_dir}/cpp/bzlib/compress.c" \ + "${repo_dir}/cpp/bzlib/crctable.c" \ + "${repo_dir}/cpp/bzlib/decompress.c" \ + "${repo_dir}/cpp/bzlib/huffman.c" \ + "${repo_dir}/cpp/bzlib/randtable.c" \ + -I"${repo_dir}/cpp" \ + -I"${repo_dir}/cpp/bzlib" \ + -O3 \ + -flto \ + --no-entry \ + -sASSERTIONS=0 \ + -sALLOW_MEMORY_GROWTH=1 \ + -sENVIRONMENT=web,worker,node \ + -sEXPORTED_FUNCTIONS='["_bsDiffFile","_bsPatchFile"]' \ + -sEXPORTED_RUNTIME_METHODS='["FS","ccall"]' \ + -sEXPORT_ES6=1 \ + -sFILESYSTEM=1 \ + -sMODULARIZE=1 \ + -sNO_EXIT_RUNTIME=1 \ + -sSINGLE_FILE=1 \ + -o "${repo_dir}/web/bsdiffpatch.mjs" diff --git a/scripts/run-android-runtime-test.sh b/scripts/run-android-runtime-test.sh new file mode 100644 index 0000000..f7c051b --- /dev/null +++ b/scripts/run-android-runtime-test.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +set -eu + +api_level="${1:?Usage: run-android-runtime-test.sh }" + +cd example/android + +run_runtime_test() { + ./gradlew :app:connectedReleaseAndroidTest \ + --stacktrace \ + -PnewArchEnabled=true \ + -PreactNativeArchitectures=x86_64 +} + +if run_runtime_test; then + exit 0 +fi + +if [ "$api_level" -ne 24 ]; then + exit 1 +fi + +echo '::warning::API 24 emulator failed; rebooting it before one retry.' +adb reboot +sleep 5 +adb wait-for-device + +boot_attempt=0 +until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = '1' ]; do + boot_attempt=$((boot_attempt + 1)) + if [ "$boot_attempt" -ge 90 ]; then + echo 'API 24 emulator did not finish rebooting within 180 seconds.' >&2 + exit 1 + fi + sleep 2 +done + +sleep 5 +run_runtime_test diff --git a/scripts/test-site-browser.mjs b/scripts/test-site-browser.mjs new file mode 100644 index 0000000..56e02fd --- /dev/null +++ b/scripts/test-site-browser.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import puppeteer from 'puppeteer-core'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const outputDirectory = path.resolve(scriptDirectory, '../site-dist'); +const chromeCandidates = [ + process.env.CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +].filter(Boolean); +const executablePath = chromeCandidates.find((candidate) => + existsSync(candidate) +); + +if (!executablePath) { + throw new Error( + 'Chrome executable not found; set CHROME_PATH to run the site test' + ); +} + +const mimeTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.mjs', 'text/javascript; charset=utf-8'], + ['.png', 'image/png'], + ['.xml', 'application/xml; charset=utf-8'], +]); + +const server = createServer(async (request, response) => { + try { + const pathname = decodeURIComponent( + new URL(request.url || '/', 'http://127.0.0.1').pathname + ); + const requestedPath = path.resolve( + outputDirectory, + `.${pathname.endsWith('/') ? `${pathname}index.html` : pathname}` + ); + if (!requestedPath.startsWith(`${outputDirectory}${path.sep}`)) { + response.writeHead(403).end('Forbidden'); + return; + } + if (!(await stat(requestedPath)).isFile()) { + response.writeHead(404).end('Not Found'); + return; + } + response.writeHead(200, { + 'Content-Type': + mimeTypes.get(path.extname(requestedPath)) || + 'application/octet-stream', + }); + response.end(await readFile(requestedPath)); + } catch { + response.writeHead(404).end('Not Found'); + } +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +const baseUrl = `http://127.0.0.1:${address.port}`; +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--disable-dev-shm-usage'], +}); + +try { + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + + await page.goto(`${baseUrl}/`, { waitUntil: 'networkidle0' }); + await page.waitForSelector('#runtime-state[data-state="ready"]'); + await page.click('#generate-patch'); + await page.waitForSelector('#playground-status[data-state="success"]', { + timeout: 30_000, + }); + + const result = await page.evaluate(() => ({ + heading: document.querySelector('h1')?.textContent, + patchSize: document.querySelector('#patch-size')?.textContent, + status: document.querySelector('#playground-status')?.textContent?.trim(), + })); + assert.match(result.heading || '', /Binary deltas/); + assert.notEqual(result.patchSize, '—'); + assert.match(result.status || '', /verified byte-for-byte/); + + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); + await page.reload({ waitUntil: 'networkidle0' }); + const mobile = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + assert.ok( + mobile.scrollWidth <= mobile.clientWidth + 1, + `mobile layout overflows by ${mobile.scrollWidth - mobile.clientWidth}px` + ); + + await page.goto(`${baseUrl}/docs/api-reference/`, { + waitUntil: 'networkidle0', + }); + assert.equal( + await page.$eval('h1', (element) => element.textContent), + 'API reference' + ); + + await page.goto(`${baseUrl}/docs/zh-CN/getting-started/`, { + waitUntil: 'networkidle0', + }); + assert.equal( + await page.$eval('h1', (element) => element.textContent), + '快速开始' + ); + assert.equal(await page.$eval('html', (element) => element.lang), 'zh-CN'); + assert.equal( + await page.$eval('a[hreflang="en"]', (element) => element.textContent), + 'English' + ); + assert.equal(pageErrors.length, 0, pageErrors.join('\n')); + console.log('Site Playground, bilingual docs, and mobile viewport passed'); +} finally { + await browser.close(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs new file mode 100644 index 0000000..d5688d5 --- /dev/null +++ b/scripts/test-site.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { readdir, readFile, stat } 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 outputDirectory = path.join(repositoryDirectory, 'site-dist'); +const documentationDirectory = path.join(repositoryDirectory, 'docs'); + +const requiredFiles = [ + 'index.html', + '404.html', + 'CNAME', + '.nojekyll', + 'assets/site.css', + 'assets/site.js', + 'assets/playground.js', + 'web/index.mjs', + 'web/worker.mjs', + 'web/operations.mjs', + 'web/bsdiffpatch.mjs', + 'docs/index.html', + 'docs/getting-started/index.html', + 'docs/api-reference/index.html', + 'docs/recipes/index.html', + 'docs/platform-support/index.html', + 'docs/architecture/index.html', + 'docs/troubleshooting/index.html', + 'docs/development/index.html', + 'docs/zh-CN/index.html', + 'docs/zh-CN/getting-started/index.html', + 'docs/zh-CN/api-reference/index.html', + 'docs/zh-CN/recipes/index.html', + 'docs/zh-CN/platform-support/index.html', + 'docs/zh-CN/architecture/index.html', + 'docs/zh-CN/troubleshooting/index.html', + 'docs/zh-CN/development/index.html', +]; + +for (const relativePath of requiredFiles) { + assert.ok( + (await stat(path.join(outputDirectory, relativePath))).isFile(), + `missing site output: ${relativePath}` + ); +} + +assert.equal( + (await readFile(path.join(outputDirectory, 'CNAME'), 'utf8')).trim(), + 'bs-dff-patch.corerobin.com' +); + +async function htmlFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() + ? htmlFiles(entryPath) + : entry.name.endsWith('.html') + ? [entryPath] + : []; + }) + ); + return nested.flat(); +} + +async function markdownFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() + ? markdownFiles(entryPath) + : entry.name.endsWith('.md') + ? [entryPath] + : []; + }) + ); + return nested.flat(); +} + +function resolveLocalReference(htmlPath, reference) { + const cleaned = reference.split('#')[0].split('?')[0]; + if (!cleaned || /^(https?:|mailto:|tel:|data:)/.test(cleaned)) { + return undefined; + } + const candidate = cleaned.startsWith('/') + ? path.join(outputDirectory, cleaned) + : path.resolve(path.dirname(htmlPath), cleaned); + if (path.extname(candidate)) { + return candidate; + } + return path.join(candidate, 'index.html'); +} + +for (const htmlPath of await htmlFiles(outputDirectory)) { + const html = await readFile(htmlPath, 'utf8'); + assert.doesNotMatch( + html, + /\{\{[A-Z_]+\}\}/, + `unresolved token in ${htmlPath}` + ); + assert.match( + html, + / match[1] + ); + for (const reference of references) { + const target = resolveLocalReference(htmlPath, reference); + if (target) { + assert.ok( + (await stat(target)).isFile(), + `broken local reference ${reference} in ${htmlPath}` + ); + } + } +} + +const markdownSources = [ + path.join(repositoryDirectory, 'README.md'), + path.join(repositoryDirectory, 'README.zh-CN.md'), + path.join(repositoryDirectory, 'CONTRIBUTING.md'), + ...(await markdownFiles(documentationDirectory)), +]; + +for (const markdownPath of markdownSources) { + const markdown = await readFile(markdownPath, 'utf8'); + const references = [...markdown.matchAll(/\]\(([^)]+)\)/g)].map( + (match) => match[1].split('#')[0] + ); + for (const reference of references) { + if (!reference || /^(https?:|mailto:|tel:|\/)/.test(reference)) { + continue; + } + const target = path.resolve(path.dirname(markdownPath), reference); + assert.ok( + target.startsWith(`${repositoryDirectory}${path.sep}`), + `Markdown reference escapes repository: ${reference} in ${markdownPath}` + ); + await assert.doesNotReject( + stat(target), + `broken Markdown reference ${reference} in ${markdownPath}` + ); + } +} + +const homepage = await readFile( + path.join(outputDirectory, 'index.html'), + 'utf8' +); +assert.match(homepage, /id="playground"/); +assert.match(homepage, /id="generate-patch"/); +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 new file mode 100644 index 0000000..d44462b --- /dev/null +++ b/scripts/test-web-browser.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import puppeteer from 'puppeteer-core'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryDirectory = path.resolve(scriptDirectory, '..'); +const chromeCandidates = [ + process.env.CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +].filter(Boolean); +const executablePath = chromeCandidates.find((candidate) => + existsSync(candidate) +); + +if (!executablePath) { + throw new Error( + 'Chrome executable not found; set CHROME_PATH to run the browser test' + ); +} + +const mimeTypes = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.mjs', 'text/javascript; charset=utf-8'], +]); + +const server = createServer(async (request, response) => { + try { + const pathname = decodeURIComponent( + new URL(request.url || '/', 'http://127.0.0.1').pathname + ); + const requestedPath = path.resolve(repositoryDirectory, `.${pathname}`); + + if (!requestedPath.startsWith(`${repositoryDirectory}${path.sep}`)) { + response.writeHead(403).end('Forbidden'); + return; + } + + if (!(await stat(requestedPath)).isFile()) { + response.writeHead(404).end('Not Found'); + return; + } + + const body = await readFile(requestedPath); + response.writeHead(200, { + 'Content-Type': + mimeTypes.get(path.extname(requestedPath)) || + 'application/octet-stream', + }); + response.end(body); + } catch { + response.writeHead(404).end('Not Found'); + } +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--disable-dev-shm-usage'], +}); + +try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${address.port}/scripts/web-test.html`, { + waitUntil: 'load', + }); + await page.waitForFunction(() => document.body.dataset.status !== 'running', { + timeout: 30_000, + }); + + const result = await page.evaluate(() => window.__bsdiffWebTestResult); + assert.deepEqual(result, { + inputsPreserved: true, + invalidInputErrorCode: 'EINVAL', + patchLength: result.patchLength, + pathApiErrorCode: 'EUNSUPPORTED', + restoredMatches: true, + }); + assert.ok(result.patchLength > 24); + console.log('Browser Web Worker diff/patch round trip passed'); +} finally { + await browser.close(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} diff --git a/scripts/test-web-metro.mjs b/scripts/test-web-metro.mjs new file mode 100644 index 0000000..b406458 --- /dev/null +++ b/scripts/test-web-metro.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFile, rm } 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 bundleBase = path.join( + os.tmpdir(), + `react-native-bs-diff-patch-metro-${process.pid}` +); +const bundlePath = `${bundleBase}.js`; +const metroPath = path.join( + repositoryDirectory, + 'example/node_modules/.bin/metro' +); + +const result = spawnSync( + metroPath, + [ + 'build', + '../scripts/web-metro-entry.js', + '--platform', + 'web', + '--out', + bundleBase, + '--config', + 'example/metro.config.js', + '--minify', + 'false', + ], + { + cwd: repositoryDirectory, + encoding: 'utf8', + } +); + +if (result.status !== 0) { + throw new Error( + `Metro Web build failed:\n${result.stdout || ''}\n${result.stderr || ''}` + ); +} + +try { + const bundle = await readFile(bundlePath, 'utf8'); + assert.match(bundle, /Web Workers are required/); + assert.match(bundle, /worker\.mjs/); + assert.doesNotMatch(bundle, /diffBytes is only available on Web/); + assert.doesNotMatch(bundle, /NativeBsDiffPatch/); + console.log('Metro selected the React Native Web entry'); +} finally { + await rm(bundlePath, { force: true }); + await rm(`${bundlePath}.map`, { force: true }); +} diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs new file mode 100644 index 0000000..d8053e9 --- /dev/null +++ b/scripts/test-web.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; + +import { runOperation } from '../web/operations.mjs'; + +const encoder = new TextEncoder(); +const oldData = encoder.encode('hello from the old file\n'.repeat(128)); +const newData = encoder.encode( + 'hello from the new file\n'.repeat(96) + 'web round trip\n'.repeat(32) +); + +const patchData = await runOperation('diff', oldData, newData); +assert.ok(patchData.byteLength > 24, 'diff should produce a non-empty patch'); +assert.equal( + new TextDecoder().decode(patchData.subarray(0, 16)), + 'ENDSLEY/BSDIFF43', + 'Web patches must use the same format as Android and iOS' +); + +const restoredData = await runOperation('patch', oldData, patchData); +assert.deepEqual( + restoredData, + newData, + 'patch should reconstruct the new bytes' +); + +await assert.rejects( + runOperation('patch', oldData, new Uint8Array([1, 2, 3])), + (error) => error && error.code === 'EWEBASSEMBLY', + 'corrupt patches should reject with a WebAssembly error' +); + +console.log('WebAssembly diff/patch round trip passed'); diff --git a/scripts/web-metro-entry.js b/scripts/web-metro-entry.js new file mode 100644 index 0000000..2f566dc --- /dev/null +++ b/scripts/web-metro-entry.js @@ -0,0 +1 @@ +export { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; diff --git a/scripts/web-test.html b/scripts/web-test.html new file mode 100644 index 0000000..55a11ce --- /dev/null +++ b/scripts/web-test.html @@ -0,0 +1,75 @@ + + + + + + BsDiffPatch Web Test + + +
    +

    BsDiffPatch Web Test

    +

    Running…

    +
    + + + diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..3ad3882 --- /dev/null +++ b/site/404.html @@ -0,0 +1,35 @@ + + + + + + + + + Page not found — react-native-bs-diff-patch + + + + +
    +
    +

    404

    +

    That byte range does not exist.

    +

    + Return to the package documentation or try the live patch playground. +

    + Back to home → +
    +
    + + + diff --git a/site/CNAME b/site/CNAME new file mode 100644 index 0000000..52dfc51 --- /dev/null +++ b/site/CNAME @@ -0,0 +1 @@ +bs-dff-patch.corerobin.com diff --git a/site/assets/playground.js b/site/assets/playground.js new file mode 100644 index 0000000..6580505 --- /dev/null +++ b/site/assets/playground.js @@ -0,0 +1,115 @@ +/* eslint-env browser */ + +import { diffBytes, patchBytes } from '../web/index.mjs'; + +const encoder = new TextEncoder(); +const oldPayload = document.querySelector('#old-payload'); +const newPayload = document.querySelector('#new-payload'); +const oldSize = document.querySelector('#old-size'); +const newSize = document.querySelector('#new-size'); +const patchSize = document.querySelector('#patch-size'); +const transferSaved = document.querySelector('#transfer-saved'); +const runtimeMs = document.querySelector('#runtime-ms'); +const runtimeState = document.querySelector('#runtime-state'); +const status = document.querySelector('#playground-status'); +const generateButton = document.querySelector('#generate-patch'); +const downloadButton = document.querySelector('#download-patch'); + +let currentPatch; + +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + return `${(bytes / 1024).toFixed(1)} KB`; +} + +function updateInputSizes() { + oldSize.value = formatBytes(encoder.encode(oldPayload.value).byteLength); + newSize.value = formatBytes(encoder.encode(newPayload.value).byteLength); +} + +function setStatus(state, message) { + status.dataset.state = state; + status.lastChild.textContent = message; +} + +function bytesEqual(left, right) { + return ( + left.byteLength === right.byteLength && + left.every((value, index) => value === right[index]) + ); +} + +async function generatePatch() { + const oldData = encoder.encode(oldPayload.value); + const newData = encoder.encode(newPayload.value); + + generateButton.disabled = true; + downloadButton.disabled = true; + generateButton.classList.add('is-running'); + setStatus('running', 'Generating patch in the Web Worker…'); + + const startedAt = performance.now(); + + try { + const patchData = await diffBytes(oldData, newData); + const restoredData = await patchBytes(oldData, patchData); + + if (!bytesEqual(restoredData, newData)) { + throw new Error('The reconstructed payload did not match the target'); + } + + const elapsed = performance.now() - startedAt; + const saved = newData.byteLength + ? (1 - patchData.byteLength / newData.byteLength) * 100 + : 0; + + currentPatch = patchData; + patchSize.textContent = formatBytes(patchData.byteLength); + transferSaved.textContent = + saved >= 0 + ? `${saved.toFixed(1)}%` + : `${Math.abs(saved).toFixed(1)}% overhead`; + transferSaved.classList.toggle('negative', saved < 0); + runtimeMs.textContent = `${elapsed.toFixed(2)} ms`; + downloadButton.disabled = false; + setStatus('success', 'Round trip verified byte-for-byte'); + } catch (error) { + currentPatch = undefined; + patchSize.textContent = '—'; + transferSaved.textContent = '—'; + runtimeMs.textContent = '—'; + setStatus( + 'error', + error instanceof Error ? error.message : 'The patch operation failed' + ); + } finally { + generateButton.disabled = false; + generateButton.classList.remove('is-running'); + } +} + +function downloadPatch() { + if (!currentPatch) { + return; + } + + const url = URL.createObjectURL( + new Blob([currentPatch], { type: 'application/octet-stream' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'playground.patch'; + link.click(); + URL.revokeObjectURL(url); +} + +oldPayload.addEventListener('input', updateInputSizes); +newPayload.addEventListener('input', updateInputSizes); +generateButton.addEventListener('click', generatePatch); +downloadButton.addEventListener('click', downloadPatch); + +updateInputSizes(); +runtimeState.dataset.state = 'ready'; +runtimeState.textContent = 'WASM ready'; diff --git a/site/assets/site.css b/site/assets/site.css new file mode 100644 index 0000000..bbbbf07 --- /dev/null +++ b/site/assets/site.css @@ -0,0 +1,1467 @@ +:root { + --ink: #060a0d; + --panel: #0a1115; + --panel-raised: #0d171c; + --panel-black: #080e11; + --line: #1c2a30; + --line-bright: #34464d; + --white: #eaf2f2; + --muted: #8da0a7; + --muted-dark: #62747b; + --lime: #b8ff3d; + --cyan: #39e6ff; + --orange: #f6bf6f; + --danger: #ff6f61; + --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', + sans-serif; + --mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--ink); + scroll-behavior: smooth; + scroll-padding-top: 88px; +} + +body { + margin: 0; + color: var(--white); + background: var(--ink); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; +} + +body, +button, +textarea, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +button:focus-visible, +a:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + z-index: 100; + top: 12px; + left: 12px; + padding: 10px 14px; + color: var(--ink); + background: var(--lime); + font: 700 12px/1 var(--mono); + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.page-grid { + position: fixed; + z-index: 0; + inset: 0; + pointer-events: none; + opacity: 0.28; +} + +.page-grid::before, +.page-grid::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: #10191e; +} + +.page-grid::before { + left: 25%; +} + +.page-grid::after { + right: 25%; +} + +.shell { + position: relative; + z-index: 1; + width: min(1480px, calc(100% - 72px)); + margin-inline: auto; + border-inline: 1px solid #10191e; +} + +.site-header { + z-index: 20; + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 32px; + padding-inline: 24px; + border-bottom: 1px solid var(--line); + background: rgba(6, 10, 13, 0.96); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 13px; + text-decoration: none; + font: 650 14px/1 var(--mono); + letter-spacing: -0.03em; +} + +.brand-mark { + width: 32px; + height: 32px; + display: grid; + flex: 0 0 auto; + place-items: center; + color: var(--ink); + background: var(--lime); + border: 1px solid var(--lime); + font: 850 13px/1 var(--mono); +} + +.nav-links { + display: flex; + align-items: center; + gap: 30px; +} + +.nav-links a, +.site-footer a { + color: #b9c6ca; + text-decoration: none; + font: 600 12px/1 var(--mono); + transition: color 150ms ease, border-color 150ms ease; +} + +.nav-links a:hover, +.site-footer a:hover { + color: var(--cyan); +} + +.github-link { + min-height: 38px; + display: inline-flex; + align-items: center; + gap: 10px; + padding-inline: 14px; + border: 1px solid var(--line-bright); +} + +.github-link:hover { + border-color: var(--cyan); +} + +.status-dot { + width: 7px; + height: 7px; + background: var(--lime); + box-shadow: 0 0 0 3px #152416; +} + +.nav-toggle { + display: none; + min-height: 38px; + color: var(--white); + background: transparent; + border: 1px solid var(--line-bright); + padding-inline: 13px; + font: 650 11px/1 var(--mono); + text-transform: uppercase; +} + +.hero { + min-height: 674px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(560px, 0.96fr); + gap: clamp(40px, 5vw, 76px); + align-items: center; + padding: 66px 34px 58px; + border-bottom: 1px solid var(--line); +} + +.hero-copy { + min-width: 0; +} + +.eyebrow, +.section-kicker { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 18px; + color: var(--cyan); + font: 700 11px/1 var(--mono); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.eyebrow::before, +.section-kicker::before { + content: ''; + width: 24px; + height: 1px; + background: var(--cyan); +} + +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} + +h1 { + max-width: 740px; + margin: 0; + font-size: clamp(48px, 5.2vw, 78px); + font-weight: 760; + line-height: 0.98; + letter-spacing: -0.066em; +} + +.headline-accent { + display: inline; + padding: 0 10px 5px; + color: var(--ink); + background: var(--lime); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.lede { + max-width: 650px; + margin: 25px 0 22px; + color: #aab9be; + font-size: 18px; + line-height: 1.56; +} + +.platform-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 18px; + padding: 0; + list-style: none; +} + +.platform-list li { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 8px; + padding-inline: 10px; + color: #b9c7cb; + border: 1px solid var(--line-bright); + font: 650 10px/1 var(--mono); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.platform-list span { + width: 5px; + height: 5px; + background: var(--cyan); +} + +.platform-list .web span { + background: var(--lime); +} + +.install-row { + min-height: 52px; + display: flex; + align-items: center; + border: 1px solid var(--line-bright); + background: var(--panel-black); +} + +.install-row .prompt { + align-self: stretch; + width: 48px; + display: grid; + flex: 0 0 auto; + place-items: center; + color: var(--lime); + border-right: 1px solid var(--line); + font: 800 15px/1 var(--mono); +} + +.install-row code { + min-width: 0; + flex: 1; + padding-inline: 16px; + color: var(--white); + font: 560 13px/1.4 var(--mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.copy-button, +.code-copy { + min-height: 32px; + margin-right: 9px; + padding-inline: 11px; + color: var(--muted); + background: transparent; + border: 1px solid var(--line); + cursor: pointer; + font: 700 10px/1 var(--mono); + text-transform: uppercase; + transition: color 150ms ease, border-color 150ms ease; +} + +.copy-button:hover, +.code-copy:hover { + color: var(--cyan); + border-color: var(--cyan); +} + +.code-window { + margin-top: 13px; + border: 1px solid var(--line); + background: var(--panel); +} + +.window-bar { + min-height: 35px; + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: 14px; + color: var(--muted); + border-bottom: 1px solid var(--line); + font: 700 9px/1 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.window-lights { + display: flex; + gap: 6px; +} + +.window-lights i { + width: 6px; + height: 6px; + background: var(--line-bright); +} + +.window-lights i:first-child { + background: var(--lime); +} + +pre, +code { + font-family: var(--mono); +} + +.code-window pre { + margin: 0; + padding: 16px 18px 18px; + color: #bac6ca; + font: 500 12px/1.62 var(--mono); + white-space: pre-wrap; +} + +.token-keyword { + color: var(--cyan); +} + +.token-function { + color: var(--lime); +} + +.token-string { + color: var(--orange); +} + +.playground { + position: relative; + min-width: 0; + background: var(--panel); + border: 1px solid var(--line-bright); + box-shadow: 8px 8px 0 #0e171b; +} + +.live-badge { + position: absolute; + z-index: 2; + top: -1px; + right: -1px; + padding: 10px 13px; + color: var(--ink); + background: var(--cyan); + font: 850 10px/1 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.playground-header { + min-height: 82px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px 130px 18px 20px; + border-bottom: 1px solid var(--line); +} + +.playground-header .section-kicker { + margin-bottom: 8px; + font-size: 9px; +} + +.playground-header h2 { + margin: 0; + font: 720 17px/1 var(--mono); +} + +.runtime-state { + display: none; +} + +.editors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--line); + border-bottom: 1px solid var(--line); +} + +.editor-panel { + min-width: 0; + display: block; + padding: 14px 15px 15px; + background: var(--panel-black); +} + +.editor-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 11px; + color: var(--muted); + font: 650 10px/1 var(--mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.editor-label strong { + color: #b9c6ca; + font-weight: 700; +} + +.editor-panel textarea { + width: 100%; + min-height: 150px; + resize: vertical; + display: block; + padding: 14px; + color: #c6d1d4; + caret-color: var(--lime); + background: #05090b; + border: 1px solid var(--line); + border-radius: 0; + font: 500 11px/1.55 var(--mono); + transition: border-color 150ms ease; +} + +.editor-panel textarea:focus { + border-color: var(--cyan); + outline: none; +} + +.play-actions { + min-height: 90px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 20px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.primary-button, +.primary-link { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 18px; + padding-inline: 20px; + color: var(--ink); + background: var(--lime); + border: 1px solid var(--lime); + box-shadow: 4px 4px 0 #263810; + cursor: pointer; + text-decoration: none; + font: 850 11px/1 var(--mono); + letter-spacing: 0.03em; + text-transform: uppercase; + transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease; +} + +.primary-button:hover, +.primary-link:hover { + transform: translate(-1px, -1px); + box-shadow: 6px 6px 0 #263810; +} + +.primary-button:active, +.primary-link:active { + transform: translate(3px, 3px); + box-shadow: 1px 1px 0 #263810; +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.68; +} + +.primary-button.is-running span { + animation: pulse-arrow 700ms steps(2, end) infinite; +} + +.secondary-button, +.secondary-link { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + padding-inline: 14px; + color: #b9c6ca; + background: transparent; + border: 1px solid var(--line-bright); + cursor: pointer; + text-decoration: none; + font: 700 10px/1 var(--mono); + text-transform: uppercase; + transition: color 150ms ease, border-color 150ms ease; +} + +.secondary-button:hover:not(:disabled), +.secondary-link:hover { + color: var(--cyan); + border-color: var(--cyan); +} + +.secondary-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.worker-note { + min-width: 0; + display: grid; + gap: 6px; + font: 500 10px/1.2 var(--mono); +} + +.worker-note strong { + color: var(--cyan); +} + +.worker-note span { + color: var(--muted); +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.metric { + min-width: 0; + min-height: 88px; + display: grid; + align-content: space-between; + padding: 14px 16px; + background: var(--panel-black); + border: 1px solid var(--line); +} + +.metric span { + color: var(--muted); + font: 700 9px/1 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric strong { + color: var(--cyan); + font: 750 23px/1 var(--mono); +} + +.metric strong.lime { + color: var(--lime); +} + +.metric strong.negative { + color: var(--orange); + font-size: 14px; +} + +.playground-footer { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-inline: 20px; + color: var(--muted-dark); + font: 600 9px/1 var(--mono); + text-transform: uppercase; +} + +.playground-status { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--muted); + text-transform: none; +} + +.playground-status i { + width: 6px; + height: 6px; + flex: 0 0 auto; + background: var(--muted-dark); +} + +.playground-status[data-state='running'] i { + background: var(--cyan); + animation: status-blink 650ms steps(2, end) infinite; +} + +.playground-status[data-state='success'] { + color: var(--lime); +} + +.playground-status[data-state='success'] i { + background: var(--lime); +} + +.playground-status[data-state='error'] { + color: var(--danger); +} + +.playground-status[data-state='error'] i { + background: var(--danger); +} + +.architecture { + display: grid; + grid-template-columns: 320px 1fr; + min-height: 250px; + border-bottom: 1px solid var(--line); +} + +.architecture-intro { + padding: 52px 34px; + border-right: 1px solid var(--line); +} + +.architecture h2, +.proof-copy h2, +.docs-cta h2, +.docs-hero h1 { + margin: 0; + font-size: clamp(32px, 3vw, 48px); + line-height: 1.02; + letter-spacing: -0.05em; +} + +.architecture-steps { + display: grid; + grid-template-columns: repeat(4, 1fr); + margin: 0; + padding: 0; + list-style: none; +} + +.architecture-steps li { + position: relative; + min-width: 0; + padding: 56px 28px 42px; + border-right: 1px solid var(--line); +} + +.architecture-steps li:last-child { + border-right: 0; +} + +.architecture-steps li:not(:last-child)::after { + content: '→'; + position: absolute; + top: 50%; + right: -10px; + z-index: 2; + color: var(--line-bright); + background: var(--ink); + font: 700 16px/1 var(--mono); +} + +.architecture-steps span, +.card-number { + display: block; + margin-bottom: 25px; + color: var(--muted-dark); + font: 700 10px/1 var(--mono); +} + +.architecture-steps strong { + font: 700 14px/1.2 var(--mono); +} + +.architecture-steps p { + margin: 12px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.proof-section { + display: grid; + grid-template-columns: minmax(320px, 0.85fr) 1.5fr; + border-bottom: 1px solid var(--line); +} + +.proof-copy { + padding: 76px 48px; + border-right: 1px solid var(--line); +} + +.proof-copy > p:not(.section-kicker) { + margin: 26px 0; + color: var(--muted); + font-size: 15px; + line-height: 1.7; +} + +.text-link { + color: var(--cyan); + font: 700 11px/1 var(--mono); + text-decoration: none; + text-transform: uppercase; +} + +.text-link:hover { + color: var(--lime); +} + +.proof-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.proof-grid article { + min-height: 230px; + padding: 38px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.proof-grid article:nth-child(even) { + border-right: 0; +} + +.proof-grid article:nth-last-child(-n + 2) { + border-bottom: 0; +} + +.proof-grid h3 { + margin: 0; + font: 750 17px/1.25 var(--mono); +} + +.proof-grid p { + margin: 16px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.65; +} + +.docs-cta { + display: grid; + grid-template-columns: 1.1fr 0.85fr auto; + align-items: center; + gap: 48px; + padding: 74px 48px; + border-bottom: 1px solid var(--line); + background: var(--panel-black); +} + +.docs-cta > p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.7; +} + +.cta-actions { + display: grid; + gap: 14px; +} + +.primary-link, +.secondary-link { + white-space: nowrap; +} + +.site-footer { + min-height: 120px; + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 40px; + padding-inline: 28px; + color: var(--muted); + border-bottom: 1px solid #10191e; +} + +.site-footer p { + margin: 0; + font-size: 12px; +} + +.site-footer nav { + display: flex; + gap: 24px; +} + +/* Documentation */ + +.docs-body .site-header { + position: sticky; + top: 0; +} + +.docs-main { + display: grid; + grid-template-columns: 270px minmax(0, 1fr); + min-height: calc(100vh - 196px); + border-bottom: 1px solid var(--line); +} + +.docs-sidebar { + position: sticky; + top: 76px; + align-self: start; + height: calc(100vh - 76px); + overflow-y: auto; + padding: 34px 24px; + border-right: 1px solid var(--line); + background: rgba(6, 10, 13, 0.94); +} + +.docs-sidebar-title { + margin: 0 0 24px; + color: var(--muted-dark); + font: 750 10px/1 var(--mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.docs-nav { + display: grid; + gap: 7px; +} + +.docs-nav a { + display: flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 0 10px; + color: var(--muted); + border: 1px solid transparent; + text-decoration: none; + font: 600 11px/1.2 var(--mono); +} + +.docs-nav a::before { + content: ''; + width: 5px; + height: 5px; + flex: 0 0 auto; + background: var(--line-bright); +} + +.docs-nav a:hover { + color: var(--cyan); +} + +.docs-nav a[aria-current='page'] { + color: var(--white); + background: var(--panel); + border-color: var(--line-bright); +} + +.docs-nav a[aria-current='page']::before { + background: var(--lime); +} + +.docs-content-wrap { + min-width: 0; + padding: 70px clamp(32px, 7vw, 110px) 100px; +} + +.docs-breadcrumb { + margin: 0 0 20px; + color: var(--cyan); + font: 700 10px/1 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs-hero { + max-width: 860px; + padding-bottom: 42px; + border-bottom: 1px solid var(--line); +} + +.docs-hero p { + max-width: 720px; + margin: 20px 0 0; + color: var(--muted); + font-size: 17px; + line-height: 1.65; +} + +.docs-content { + max-width: 860px; + padding-top: 18px; + color: #b7c4c8; + font-size: 15px; + line-height: 1.75; +} + +.docs-content h2, +.docs-content h3, +.docs-content h4 { + color: var(--white); + scroll-margin-top: 96px; +} + +.docs-content h2 { + margin: 56px 0 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); + font-size: 28px; + line-height: 1.15; + letter-spacing: -0.04em; +} + +.docs-content h3 { + margin: 38px 0 14px; + font: 750 17px/1.3 var(--mono); +} + +.docs-content p { + margin: 16px 0; +} + +.docs-content a { + color: var(--cyan); + text-underline-offset: 3px; +} + +.docs-content a:hover { + color: var(--lime); +} + +.docs-content ul, +.docs-content ol { + margin: 18px 0; + padding-left: 24px; +} + +.docs-content li { + margin-block: 7px; + padding-left: 5px; +} + +.docs-content code:not(pre code) { + padding: 2px 5px; + color: var(--lime); + background: var(--panel); + border: 1px solid var(--line); + font-size: 0.9em; +} + +.docs-content pre { + position: relative; + overflow-x: auto; + margin: 22px 0; + padding: 20px 22px; + color: #c7d2d5; + background: #05090b; + border: 1px solid var(--line-bright); + box-shadow: 5px 5px 0 #0e171b; + font: 500 12px/1.65 var(--mono); +} + +.docs-content pre .code-copy { + position: absolute; + top: 9px; + right: 0; + background: #05090b; +} + +.docs-content table { + width: 100%; + margin: 24px 0; + border-collapse: collapse; + font-size: 13px; +} + +.docs-content th, +.docs-content td { + padding: 12px 14px; + text-align: left; + border: 1px solid var(--line); +} + +.docs-content th { + color: var(--white); + background: var(--panel-raised); + font: 700 10px/1.3 var(--mono); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.docs-content td { + background: rgba(10, 17, 21, 0.56); +} + +.docs-card-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-top: 32px; +} + +.docs-card { + min-height: 180px; + display: grid; + align-content: space-between; + padding: 24px; + text-decoration: none; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: 5px 5px 0 #0e171b; + transition: border-color 150ms ease, transform 150ms ease; +} + +.docs-card:hover { + border-color: var(--cyan); + transform: translate(-2px, -2px); +} + +.docs-card span { + color: var(--muted-dark); + font: 700 10px/1 var(--mono); +} + +.docs-card h2 { + margin: 25px 0 10px; + color: var(--white); + font: 750 17px/1.25 var(--mono); +} + +.docs-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.not-found { + min-height: calc(100vh - 196px); + display: grid; + place-items: center; + padding: 60px 24px; + border-bottom: 1px solid var(--line); + text-align: center; +} + +.not-found-code { + margin: 0; + color: var(--lime); + font: 800 clamp(70px, 15vw, 160px) / 0.9 var(--mono); +} + +.not-found h1 { + margin-top: 28px; + font-size: 38px; +} + +.not-found p { + color: var(--muted); +} + +@keyframes status-blink { + 50% { + opacity: 0.3; + } +} + +@keyframes pulse-arrow { + 50% { + opacity: 0.25; + } +} + +@media (max-width: 1180px) { + .hero { + grid-template-columns: 1fr; + } + + .hero-copy { + max-width: 820px; + } + + .playground { + max-width: 900px; + } + + .architecture { + grid-template-columns: 1fr; + } + + .architecture-intro { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .docs-cta { + grid-template-columns: 1fr 1fr; + } + + .cta-actions { + grid-column: 1 / -1; + grid-template-columns: repeat(2, max-content); + } +} + +@media (max-width: 860px) { + .shell { + width: calc(100% - 32px); + } + + .site-header { + min-height: 68px; + padding-inline: 16px; + } + + .brand { + font-size: 12px; + } + + .nav-toggle { + display: inline-flex; + align-items: center; + } + + .nav-links { + position: absolute; + top: 67px; + right: -1px; + left: -1px; + display: none; + align-items: stretch; + padding: 14px; + background: var(--panel-black); + border: 1px solid var(--line-bright); + } + + .nav-links.is-open { + display: grid; + } + + .nav-links a { + min-height: 42px; + display: flex; + align-items: center; + padding-inline: 12px; + } + + .github-link { + justify-content: center; + } + + .hero { + gap: 48px; + padding: 52px 22px; + } + + h1 { + font-size: clamp(44px, 12vw, 66px); + } + + .lede { + font-size: 16px; + } + + .editors, + .metrics, + .proof-grid { + grid-template-columns: 1fr; + } + + .editor-panel:first-child, + .metric:not(:last-child), + .proof-grid article { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .proof-grid article:last-child { + border-bottom: 0; + } + + .play-actions { + grid-template-columns: 1fr 1fr; + } + + .worker-note { + order: 3; + grid-column: 1 / -1; + } + + .architecture-steps { + grid-template-columns: repeat(2, 1fr); + } + + .architecture-steps li:nth-child(2) { + border-right: 0; + } + + .architecture-steps li:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + + .architecture-steps li:nth-child(2)::after { + display: none; + } + + .proof-section, + .docs-cta { + grid-template-columns: 1fr; + } + + .proof-copy { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .docs-cta { + gap: 28px; + padding: 54px 28px; + } + + .cta-actions { + grid-column: auto; + } + + .site-footer { + grid-template-columns: 1fr; + gap: 18px; + padding-block: 30px; + } + + .docs-main { + grid-template-columns: 1fr; + } + + .docs-sidebar { + position: static; + height: auto; + padding: 18px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .docs-sidebar-title { + margin-bottom: 12px; + } + + .docs-nav { + grid-template-columns: repeat(2, 1fr); + } + + .docs-content-wrap { + padding: 48px 24px 80px; + } +} + +@media (max-width: 560px) { + .shell { + width: calc(100% - 20px); + } + + .brand > span:last-child { + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .hero { + padding: 44px 14px; + } + + .eyebrow { + font-size: 9px; + } + + .platform-list li { + flex: 1 1 calc(50% - 8px); + } + + .install-row code { + font-size: 10px; + } + + .code-window pre { + font-size: 10px; + } + + .live-badge { + padding: 8px 9px; + font-size: 8px; + } + + .playground-header { + padding: 18px 92px 18px 15px; + } + + .editor-panel { + padding-inline: 10px; + } + + .editor-panel textarea { + min-height: 170px; + font-size: 10px; + } + + .play-actions { + grid-template-columns: 1fr; + gap: 12px; + padding-inline: 14px; + } + + .worker-note { + order: initial; + grid-column: auto; + } + + .primary-button, + .secondary-button { + width: 100%; + } + + .metrics { + padding-inline: 14px; + } + + .playground-footer { + align-items: flex-start; + flex-direction: column; + padding-block: 14px; + } + + .architecture-intro, + .proof-copy { + padding: 44px 24px; + } + + .architecture-steps { + grid-template-columns: 1fr; + } + + .architecture-steps li, + .architecture-steps li:nth-child(2) { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .architecture-steps li:last-child { + border-bottom: 0; + } + + .architecture-steps li::after { + display: none; + } + + .proof-grid article { + min-height: 190px; + padding: 28px 24px; + } + + .cta-actions { + grid-template-columns: 1fr; + } + + .docs-nav, + .docs-card-grid { + grid-template-columns: 1fr; + } + + .docs-content-wrap { + padding-inline: 18px; + } + + .docs-content { + font-size: 14px; + } + + .docs-content table { + display: block; + overflow-x: auto; + white-space: nowrap; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/site/assets/site.js b/site/assets/site.js new file mode 100644 index 0000000..d0ece0d --- /dev/null +++ b/site/assets/site.js @@ -0,0 +1,63 @@ +/* eslint-env browser */ + +const navToggle = document.querySelector('.nav-toggle'); +const navLinks = document.querySelector('.nav-links'); + +if (navToggle && navLinks) { + navToggle.addEventListener('click', () => { + const expanded = navToggle.getAttribute('aria-expanded') === 'true'; + navToggle.setAttribute('aria-expanded', String(!expanded)); + navToggle.textContent = expanded + ? navToggle.dataset.closedLabel || 'Menu' + : navToggle.dataset.openLabel || 'Close'; + navLinks.classList.toggle('is-open', !expanded); + }); + + navLinks.addEventListener('click', (event) => { + if (event.target instanceof HTMLAnchorElement) { + navToggle.setAttribute('aria-expanded', 'false'); + navToggle.textContent = navToggle.dataset.closedLabel || 'Menu'; + navLinks.classList.remove('is-open'); + } + }); +} + +for (const button of document.querySelectorAll('[data-copy]')) { + button.addEventListener('click', async () => { + const value = button.getAttribute('data-copy'); + if (!value) { + return; + } + + try { + await navigator.clipboard.writeText(value); + const previous = button.textContent; + button.textContent = 'Copied'; + setTimeout(() => { + button.textContent = previous; + }, 1200); + } catch { + button.textContent = 'Select'; + } + }); +} + +for (const code of document.querySelectorAll('.docs-content pre')) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'code-copy'; + button.textContent = 'Copy'; + button.setAttribute('aria-label', 'Copy code block'); + button.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code.textContent || ''); + button.textContent = 'Copied'; + setTimeout(() => { + button.textContent = 'Copy'; + }, 1200); + } catch { + button.textContent = 'Select code'; + } + }); + code.append(button); +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..952eca6 --- /dev/null +++ b/site/index.html @@ -0,0 +1,319 @@ + + + + + + + + + + + + + + + + react-native-bs-diff-patch — Binary patches everywhere RN runs + + + + + + + + +
    +
    +
    +

    Native C core · WebAssembly worker

    +

    + Binary deltas.
    + Everywhere RN
    + runs. +

    +

    + Create and apply compact binary patches across iOS, Android, and + React Native Web—with one compatible patch format and typed API. +

    + +
      +
    • iOS · ObjC++
    • +
    • Android · JNI
    • +
    • Web · WASM
    • +
    • New Architecture
    • +
    + +
    + + npm i react-native-bs-diff-patch + +
    + +
    +
    + quick-start.ts + +
    +
    import { diffBytes, patchBytes } from 'react-native-bs-diff-patch';
    +
    +const delta = await diffBytes(oldBytes, newBytes);
    +const restored = await patchBytes(oldBytes, delta);
    +
    +
    + +
    +
    Live / WASM
    +
    +
    +

    Try the real implementation

    +

    Patch playground

    +
    + + Loading runtime + +
    + +
    + + +
    + +
    + +
    + Worker isolated + No payload data leaves this page. +
    + +
    + +
    +
    + Patch size + +
    +
    + Transfer saved + +
    +
    + Runtime + +
    +
    + +
    + + + Ready to generate and verify a patch + + ENDSLEY / BSDIFF43 +
    +
    +
    + +
    +
    +

    Under the hood

    +

    One format.
    Three runtimes.

    +
    +
      +
    1. + 01 + Typed API +

      Paths on native. Binary inputs on Web.

      +
    2. +
    3. + 02 + Worker boundary +

      Serial native queues or an isolated module Worker.

      +
    4. +
    5. + 03 + Shared C core +

      The same bsdiff and bzip2 sources everywhere.

      +
    6. +
    7. + 04 + Compact delta +

      A compatible `ENDSLEY/BSDIFF43` patch.

      +
    8. +
    +
    + +
    +
    +

    Designed for integration

    +

    + Native performance.
    Explicit platform contracts. +

    +

    + Expensive work stays off React Native's module queue and the browser + main thread. Unsupported API families reject clearly instead of + guessing at filesystem behavior. +

    + Read the architecture → +
    +
    +
    + 01 +

    New Architecture ready

    +

    + Version-aware Android packages and generated iOS TurboModule + registration. +

    +
    +
    + 02 +

    Web Worker by default

    +

    + WASM work runs outside the page thread and terminates after each + operation. +

    +
    +
    + 03 +

    Cross-platform patches

    +

    + Create a patch on one supported runtime and apply it on another. +

    +
    +
    + 04 +

    Release-grade checks

    +

    + Native matrices, device assertions, browser round trips, Metro and + package gates. +

    +
    +
    +
    + +
    +
    +

    Documentation

    +

    From first patch to production boundaries.

    +
    +

    + Follow platform-specific setup, inspect every API and error code, and + understand memory, bundler, and compatibility constraints before you + ship. +

    + +
    +
    + +
    + +

    MIT licensed. Built for React Native runtimes.

    + +
    + + + + + diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..a6e5d04 --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://bs-dff-patch.corerobin.com/sitemap.xml diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 0000000..726d4a4 --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,20 @@ + + + https://bs-dff-patch.corerobin.com/ + https://bs-dff-patch.corerobin.com/docs/ + https://bs-dff-patch.corerobin.com/docs/getting-started/ + https://bs-dff-patch.corerobin.com/docs/api-reference/ + https://bs-dff-patch.corerobin.com/docs/recipes/ + https://bs-dff-patch.corerobin.com/docs/platform-support/ + https://bs-dff-patch.corerobin.com/docs/architecture/ + https://bs-dff-patch.corerobin.com/docs/troubleshooting/ + https://bs-dff-patch.corerobin.com/docs/development/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/getting-started/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/api-reference/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/recipes/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/platform-support/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/architecture/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/troubleshooting/ + https://bs-dff-patch.corerobin.com/docs/zh-CN/development/ + diff --git a/src/NativeBsDiffPatch.ts b/src/NativeBsDiffPatch.ts new file mode 100644 index 0000000..c017957 --- /dev/null +++ b/src/NativeBsDiffPatch.ts @@ -0,0 +1,9 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + patch(oldFile: string, newFile: string, patchFile: string): Promise; + diff(oldFile: string, newFile: string, patchFile: string): Promise; +} + +export default TurboModuleRegistry.getEnforcing('BsDiffPatch'); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index bf84291..c33a0d9 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1 +1,41 @@ -it.todo('write a test'); +const mockPatch = jest.fn, [string, string, string]>(() => + Promise.resolve(0) +); +const mockDiff = jest.fn, [string, string, string]>(() => + Promise.resolve(0) +); + +jest.mock('../NativeBsDiffPatch', () => ({ + patch: (oldFile: string, newFile: string, patchFile: string) => + mockPatch(oldFile, newFile, patchFile), + diff: (oldFile: string, newFile: string, patchFile: string) => + mockDiff(oldFile, newFile, patchFile), +})); + +import { diff, diffBytes, patch, patchBytes } from '../index'; + +describe('BsDiffPatch TurboModule facade', () => { + beforeEach(() => { + mockDiff.mockClear(); + mockPatch.mockClear(); + }); + + it('delegates diff arguments and result', async () => { + await expect(diff('old', 'new', 'patch')).resolves.toBe(0); + expect(mockDiff).toHaveBeenCalledWith('old', 'new', 'patch'); + }); + + it('delegates patch arguments and result', async () => { + await expect(patch('old', 'new', 'patch')).resolves.toBe(0); + expect(mockPatch).toHaveBeenCalledWith('old', 'new', 'patch'); + }); + + it.each([ + ['diffBytes', diffBytes], + ['patchBytes', patchBytes], + ] as const)('rejects Web-only %s on native', async (_, operation) => { + await expect( + operation(new Uint8Array([1]), new Uint8Array([2])) + ).rejects.toMatchObject({ code: 'EUNSUPPORTED' }); + }); +}); diff --git a/src/index.ts b/src/index.ts index b74e1c1..26b21b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,6 @@ -import { NativeModules, Platform } from 'react-native'; +import BsDiffPatch from './NativeBsDiffPatch'; -const LINKING_ERROR = - `The package 'react-native-bs-diff-patch' doesn't seem to be linked. Make sure: \n\n` + - Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + - '- You rebuilt the app after installing the package\n' + - '- You are not using Expo Go\n'; - -const BsDiffPatch = NativeModules.BsDiffPatch - ? NativeModules.BsDiffPatch - : new Proxy( - {}, - { - get() { - throw new Error(LINKING_ERROR); - }, - } - ); +export type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; /** * generate new file from old file and patch file @@ -45,3 +30,31 @@ export function diff( ): Promise { return BsDiffPatch.diff(oldFile, newFile, patchFile); } + +function rejectWebOnlyApi(methodName: string): Promise { + const error = new Error( + `${methodName} is only available on Web; use diff/patch with file paths on native platforms` + ) as Error & { code: string }; + error.code = 'EUNSUPPORTED'; + return Promise.reject(error); +} + +/** + * Generate a binary patch in a browser Web Worker. + */ +export function diffBytes( + _oldData: BinaryInput, + _newData: BinaryInput +): Promise { + return rejectWebOnlyApi('diffBytes'); +} + +/** + * Apply a binary patch in a browser Web Worker. + */ +export function patchBytes( + _oldData: BinaryInput, + _patchData: BinaryInput +): Promise { + return rejectWebOnlyApi('patchBytes'); +} diff --git a/src/index.web.ts b/src/index.web.ts new file mode 100644 index 0000000..e50b246 --- /dev/null +++ b/src/index.web.ts @@ -0,0 +1,2 @@ +export type { BinaryInput } from '../web/index.mjs'; +export { diff, diffBytes, patch, patchBytes } from '../web/index.mjs'; diff --git a/tsconfig.build.json b/tsconfig.build.json index 2a21c28..1b62ac8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig", - "exclude": ["example"] + "exclude": ["build", "example", "lib", "node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index 263f4fa..9a13416 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,6 @@ "strict": true, "target": "esnext", "verbatimModuleSyntax": true - } + }, + "exclude": ["build", "lib", "node_modules"] } diff --git a/web/bsdiffpatch.mjs b/web/bsdiffpatch.mjs new file mode 100644 index 0000000..106150e Binary files /dev/null and b/web/bsdiffpatch.mjs differ diff --git a/web/index.d.mts b/web/index.d.mts new file mode 100644 index 0000000..9958046 --- /dev/null +++ b/web/index.d.mts @@ -0,0 +1,23 @@ +export type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; + +export function diff( + oldFile: string, + newFile: string, + patchFile: string +): Promise; + +export function patch( + oldFile: string, + newFile: string, + patchFile: string +): Promise; + +export function diffBytes( + oldData: BinaryInput, + newData: BinaryInput +): Promise; + +export function patchBytes( + oldData: BinaryInput, + patchData: BinaryInput +): Promise; diff --git a/web/index.mjs b/web/index.mjs new file mode 100644 index 0000000..afbb2ab --- /dev/null +++ b/web/index.mjs @@ -0,0 +1,129 @@ +function createError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +async function toUint8Array(input, fieldName) { + if (input instanceof ArrayBuffer) { + return new Uint8Array(input.slice(0)); + } + + if (ArrayBuffer.isView(input)) { + return new Uint8Array( + input.buffer, + input.byteOffset, + input.byteLength + ).slice(); + } + + if (typeof Blob !== 'undefined' && input instanceof Blob) { + return new Uint8Array(await input.arrayBuffer()); + } + + throw createError( + 'EINVAL', + `${fieldName} must be an ArrayBuffer, ArrayBufferView, or Blob` + ); +} + +async function runWorker(operation, oldInput, input) { + if (typeof Worker === 'undefined') { + throw createError( + 'EUNSUPPORTED', + 'Web Workers are required to run react-native-bs-diff-patch on Web' + ); + } + + const [oldFileData, inputFileData] = await Promise.all([ + toUint8Array(oldInput, 'oldData'), + toUint8Array(input, operation === 'diff' ? 'newData' : 'patchData'), + ]); + + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('./worker.mjs', import.meta.url), { + type: 'module', + }); + let settled = false; + + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + worker.terminate(); + callback(); + }; + + worker.onmessage = (event) => { + if (event.data && event.data.ok) { + finish(() => resolve(event.data.output)); + return; + } + + const workerError = event.data && event.data.error; + finish(() => + reject( + createError( + workerError && workerError.code ? workerError.code : 'EWEBASSEMBLY', + workerError && workerError.message + ? workerError.message + : `${operation} worker failed` + ) + ) + ); + }; + + worker.onerror = (event) => { + finish(() => + reject( + createError( + 'EWEBASSEMBLY', + event.message || `${operation} worker failed to load` + ) + ) + ); + }; + + worker.onmessageerror = () => { + finish(() => + reject( + createError( + 'EWEBASSEMBLY', + `${operation} worker response was invalid` + ) + ) + ); + }; + + worker.postMessage({ operation, oldFileData, inputFileData }, [ + oldFileData.buffer, + inputFileData.buffer, + ]); + }); +} + +function rejectPathApi(methodName) { + return Promise.reject( + createError( + 'EUNSUPPORTED', + `${methodName} uses native file paths and is not available on Web; use ${methodName}Bytes instead` + ) + ); +} + +export function diff() { + return rejectPathApi('diff'); +} + +export function patch() { + return rejectPathApi('patch'); +} + +export function diffBytes(oldData, newData) { + return runWorker('diff', oldData, newData); +} + +export function patchBytes(oldData, patchData) { + return runWorker('patch', oldData, patchData); +} diff --git a/web/operations.mjs b/web/operations.mjs new file mode 100644 index 0000000..a78a487 --- /dev/null +++ b/web/operations.mjs @@ -0,0 +1,70 @@ +import createBsDiffPatchModule from './bsdiffpatch.mjs'; + +const OLD_FILE = '/old-file'; +const INPUT_FILE = '/input-file'; +const OUTPUT_FILE = '/output-file'; +const PATCH_MAGIC = new Uint8Array([ + 69, 78, 68, 83, 76, 69, 89, 47, 66, 83, 68, 73, 70, 70, 52, 51, +]); + +function validatePatchHeader(patchData) { + if (patchData.byteLength < 24) { + throw new Error('corrupt patch header'); + } + + for (let index = 0; index < PATCH_MAGIC.length; index += 1) { + if (patchData[index] !== PATCH_MAGIC[index]) { + throw new Error('corrupt patch signature'); + } + } + + if ((patchData[23] & 0x80) !== 0) { + throw new Error('corrupt patch output size'); + } +} + +function createOperationError(operation, error) { + const message = + error instanceof Error && error.message + ? error.message + : String(error || 'unknown WebAssembly error'); + const wrapped = new Error(`${operation} failed: ${message}`); + wrapped.code = 'EWEBASSEMBLY'; + return wrapped; +} + +export async function runOperation(operation, oldFileData, inputFileData) { + try { + if (operation === 'patch') { + validatePatchHeader(inputFileData); + } + + const module = await createBsDiffPatchModule({ + print: () => {}, + printErr: () => {}, + }); + + module.FS.writeFile(OLD_FILE, oldFileData); + module.FS.writeFile(INPUT_FILE, inputFileData); + + const functionName = operation === 'diff' ? 'bsDiffFile' : 'bsPatchFile'; + const args = + operation === 'diff' + ? [OLD_FILE, INPUT_FILE, OUTPUT_FILE] + : [OLD_FILE, OUTPUT_FILE, INPUT_FILE]; + const result = module.ccall( + functionName, + 'number', + ['string', 'string', 'string'], + args + ); + + if (result !== 0) { + throw new Error(`native function returned ${result}`); + } + + return module.FS.readFile(OUTPUT_FILE).slice(); + } catch (error) { + throw createOperationError(operation, error); + } +} diff --git a/web/worker.mjs b/web/worker.mjs new file mode 100644 index 0000000..9541493 --- /dev/null +++ b/web/worker.mjs @@ -0,0 +1,21 @@ +import { runOperation } from './operations.mjs'; + +self.onmessage = async (event) => { + const { operation, oldFileData, inputFileData } = event.data; + + try { + const output = await runOperation(operation, oldFileData, inputFileData); + self.postMessage({ ok: true, output }, [output.buffer]); + } catch (error) { + self.postMessage({ + ok: false, + error: { + code: error && error.code ? error.code : 'EWEBASSEMBLY', + message: + error instanceof Error + ? error.message + : String(error || 'unknown error'), + }, + }); + } +}; diff --git a/yarn.lock b/yarn.lock index 9cbddec..238c828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2646,6 +2646,24 @@ __metadata: languageName: node linkType: hard +"@puppeteer/browsers@npm:2.3.0": + version: 2.3.0 + resolution: "@puppeteer/browsers@npm:2.3.0" + dependencies: + debug: ^4.3.5 + extract-zip: ^2.0.1 + progress: ^2.0.3 + proxy-agent: ^6.4.0 + semver: ^7.6.3 + tar-fs: ^3.0.6 + unbzip2-stream: ^1.4.3 + yargs: ^17.7.2 + bin: + browsers: lib/cjs/main-cli.js + checksum: dbfae1f0a3cb5ee07711eb0247d5f61039989094858989cede3f86bfef59224c72df17a1b898266e5ba7c6a7032ab647c59ad3df8f76771ef65d8974a3f93f19 + languageName: node + linkType: hard + "@react-native-community/cli-clean@npm:12.3.0": version: 12.3.0 resolution: "@react-native-community/cli-clean@npm:12.3.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2F%40react-native-community%2Fcli-clean%2F-%2Fcli-clean-12.3.0.tgz" @@ -3154,6 +3172,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/quickjs-emscripten@npm:^0.23.0": + version: 0.23.0 + resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" + checksum: c350a2947ffb80b22e14ff35099fd582d1340d65723384a0fd0515e905e2534459ad2f301a43279a37308a27c99273c932e64649abd57d0bb3ca8c557150eccc + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2F%40tsconfig%2Fnode10%2F-%2Fnode10-1.0.9.tgz" @@ -3398,6 +3423,15 @@ __metadata: languageName: node linkType: hard +"@types/yauzl@npm:^2.9.1": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "*" + checksum: 5ee966ea7bd6b2802f31ad4281c92c4c0b6dfa593c378a2582c58541fa113bec3d70eb0696b34ad95e8e6861a884cba6c3e351285816693ed176222f840a8c08 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.30.5": version: 5.62.0 resolution: "@typescript-eslint/eslint-plugin@npm:5.62.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2F%40typescript-eslint%2Feslint-plugin%2F-%2Feslint-plugin-5.62.0.tgz" @@ -3623,6 +3657,13 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa + languageName: node + linkType: hard + "aggregate-error@npm:^3.0.0": version: 3.1.0 resolution: "aggregate-error@npm:3.1.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Faggregate-error%2F-%2Faggregate-error-3.1.0.tgz" @@ -3969,6 +4010,18 @@ __metadata: languageName: node linkType: hard +"b4a@npm:^1.6.4, b4a@npm:^1.8.1": + version: 1.8.1 + resolution: "b4a@npm:1.8.1" + peerDependencies: + react-native-b4a: "*" + peerDependenciesMeta: + react-native-b4a: + optional: true + checksum: a34131bba0eb004e5537f5ec0b0d74cd2c075832f65b8dc1a2666f895a34ce47eb553b47b4949bbb312cfdf47760da8cde3537c691b5133b0d6b132af8e3b16f + languageName: node + linkType: hard + "babel-core@npm:^7.0.0-bridge.0": version: 7.0.0-bridge.0 resolution: "babel-core@npm:7.0.0-bridge.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fbabel-core%2F-%2Fbabel-core-7.0.0-bridge.0.tgz" @@ -4119,6 +4172,74 @@ __metadata: languageName: node linkType: hard +"bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": + version: 2.9.1 + resolution: "bare-events@npm:2.9.1" + peerDependencies: + bare-abort-controller: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + checksum: 73f9cb5811dcf2414baf8bb6b0dd8eb6aba65ba1332da41482c1a0e77cc5e4e6950f1b37230780c7db6b82d4e3849228f1dce222216edffb381ad59cf071087b + languageName: node + linkType: hard + +"bare-fs@npm:^4.0.1, bare-fs@npm:^4.5.5": + version: 4.7.4 + resolution: "bare-fs@npm:4.7.4" + dependencies: + bare-events: ^2.5.4 + bare-path: ^3.0.0 + bare-stream: ^2.6.4 + bare-url: ^2.2.2 + fast-fifo: ^1.3.2 + peerDependencies: + bare-buffer: "*" + peerDependenciesMeta: + bare-buffer: + optional: true + checksum: 9b4ddebd547827ca9b2f0123c90d70c00ed83ba28ddd87d5da8447ff91b7314b52025f417888b01924105ceabc0455bb0e439d2f759412f25aa822b2c7d31314 + languageName: node + linkType: hard + +"bare-path@npm:^3.0.0": + version: 3.1.1 + resolution: "bare-path@npm:3.1.1" + checksum: 0760d1eface8841b1a25f743197ae278577762590ad18de4455a467031cfe2f5ac2b534d53bf9701f59404bd299dc2e66bce95b898c6a6af1686b8acaeca403f + languageName: node + linkType: hard + +"bare-stream@npm:^2.6.4": + version: 2.13.3 + resolution: "bare-stream@npm:2.13.3" + dependencies: + b4a: ^1.8.1 + streamx: ^2.25.0 + teex: ^1.0.1 + peerDependencies: + bare-abort-controller: "*" + bare-buffer: "*" + bare-events: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + checksum: ff3b52e41beac20f4984655c2b026d14b87941be86da23e2da34cd20e81930960e2081d3489ffa5a49010c6b98a64ea438593c3bcb4839a8f9e8553153de5e77 + languageName: node + linkType: hard + +"bare-url@npm:^2.2.2": + version: 2.4.5 + resolution: "bare-url@npm:2.4.5" + dependencies: + bare-path: ^3.0.0 + checksum: 443e2d5cfc69c518448b2cdb59ffcdc745507e4cce20cda004c79a113eef99685ffa0a6af866e30075b27cfcb519a638e0c2d909be41960d9e57cdb1267d2401 + languageName: node + linkType: hard + "base-64@npm:^0.1.0": version: 0.1.0 resolution: "base-64@npm:0.1.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fbase-64%2F-%2Fbase-64-0.1.0.tgz" @@ -4252,6 +4373,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fbuffer-from%2F-%2Fbuffer-from-1.1.2.tgz" @@ -4259,6 +4387,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.2.1": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.1.13 + checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84 + languageName: node + linkType: hard + "buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fbuffer%2F-%2Fbuffer-5.7.1.tgz" @@ -4501,6 +4639,19 @@ __metadata: languageName: node linkType: hard +"chromium-bidi@npm:0.6.3": + version: 0.6.3 + resolution: "chromium-bidi@npm:0.6.3" + dependencies: + mitt: 3.0.1 + urlpattern-polyfill: 10.0.0 + zod: 3.23.8 + peerDependencies: + devtools-protocol: "*" + checksum: 4c96419e8f9cf77340948f89cb388e18fb7621993853448f53b7f532a405c6f594e341ae3d9d5f3e73f27bde142cd6b4a0b5984fe88a7758393f76f6f7974705 + languageName: node + linkType: hard + "chromium-edge-launcher@npm:^1.0.0": version: 1.0.0 resolution: "chromium-edge-launcher@npm:1.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fchromium-edge-launcher%2F-%2Fchromium-edge-launcher-1.0.0.tgz" @@ -5237,6 +5388,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.5, debug@npm:^4.3.6": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 + languageName: node + linkType: hard + "decamelize-keys@npm:^1.1.0": version: 1.1.1 resolution: "decamelize-keys@npm:1.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fdecamelize-keys%2F-%2Fdecamelize-keys-1.1.1.tgz" @@ -5377,6 +5540,17 @@ __metadata: languageName: node linkType: hard +"degenerator@npm:^5.0.0": + version: 5.0.1 + resolution: "degenerator@npm:5.0.1" + dependencies: + ast-types: ^0.13.4 + escodegen: ^2.1.0 + esprima: ^4.0.1 + checksum: a64fa39cdf6c2edd75188157d32338ee9de7193d7dbb2aeb4acb1eb30fa4a15ed80ba8dae9bd4d7b085472cf174a5baf81adb761aaa8e326771392c922084152 + languageName: node + linkType: hard + "del-cli@npm:^5.0.0": version: 5.1.0 resolution: "del-cli@npm:5.1.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fdel-cli%2F-%2Fdel-cli-5.1.0.tgz" @@ -5468,6 +5642,13 @@ __metadata: languageName: node linkType: hard +"devtools-protocol@npm:0.0.1312386": + version: 0.0.1312386 + resolution: "devtools-protocol@npm:0.0.1312386" + checksum: c6f68bce3257a6f4c832d2063fddf23b76d45f5cdaace83786c24802e12eeead3613abb54e3422e6fa95cab431fdff65ba7caf3665f7f22676df06a206b49e45 + languageName: node + linkType: hard + "diff-sequences@npm:^28.1.1": version: 28.1.1 resolution: "diff-sequences@npm:28.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fdiff-sequences%2F-%2Fdiff-sequences-28.1.1.tgz" @@ -5844,6 +6025,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.1.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: ^4.0.1 + estraverse: ^5.2.0 + esutils: ^2.0.2 + source-map: ~0.6.1 + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 096696407e161305cd05aebb95134ad176708bc5cb13d0dcc89a5fcbb959b8ed757e7f2591a5f8036f8f4952d4a724de0df14cd419e29212729fa6df5ce16bf6 + languageName: node + linkType: hard + "eslint-config-prettier@npm:^8.5.0": version: 8.10.0 resolution: "eslint-config-prettier@npm:8.10.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Feslint-config-prettier%2F-%2Feslint-config-prettier-8.10.0.tgz" @@ -6121,6 +6320,15 @@ __metadata: languageName: node linkType: hard +"events-universal@npm:^1.0.0": + version: 1.0.1 + resolution: "events-universal@npm:1.0.1" + dependencies: + bare-events: ^2.7.0 + checksum: fb8451c98535bde30585004303a368d55c38e5bc3ed6aa9b5d29fecaabaf8ec276a33ff77dcc1d1c05eecf83b8161f184cabc9a03b76a06c10e9a4ce827a6abc + languageName: node + linkType: hard + "execa@npm:7.1.1": version: 7.1.1 resolution: "execa@npm:7.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fexeca%2F-%2Fexeca-7.1.1.tgz" @@ -6227,6 +6435,23 @@ __metadata: languageName: node linkType: hard +"extract-zip@npm:^2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": ^2.9.1 + debug: ^4.1.1 + get-stream: ^5.1.0 + yauzl: ^2.10.0 + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ffast-deep-equal%2F-%2Ffast-deep-equal-3.1.3.tgz" @@ -6241,6 +6466,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 6bfcba3e4df5af7be3332703b69a7898a8ed7020837ec4395bb341bd96cc3a6d86c3f6071dd98da289618cf2234c70d84b2a6f09a33dd6f988b1ff60d8e54275 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0": version: 3.3.2 resolution: "fast-glob@npm:3.3.2::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ffast-glob%2F-%2Ffast-glob-3.3.2.tgz" @@ -6297,6 +6529,15 @@ __metadata: languageName: node linkType: hard +"fd-slicer@npm:~1.1.0": + version: 1.1.0 + resolution: "fd-slicer@npm:1.1.0" + dependencies: + pend: ~1.2.0 + checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2 + languageName: node + linkType: hard + "fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": version: 3.2.0 resolution: "fetch-blob@npm:3.2.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ffetch-blob%2F-%2Ffetch-blob-3.2.0.tgz" @@ -6649,6 +6890,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^5.1.0": + version: 5.2.0 + resolution: "get-stream@npm:5.2.0" + dependencies: + pump: ^3.0.0 + checksum: 8bc1a23174a06b2b4ce600df38d6c98d2ef6d84e020c1ddad632ad75bac4e092eeb40e4c09e0761c35fc2dbc5e7fff5dab5e763a383582c4a167dd69a905bd12 + languageName: node + linkType: hard + "get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fget-stream%2F-%2Fget-stream-6.0.1.tgz" @@ -7123,6 +7373,16 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^7.0.1": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: ^7.1.0 + debug: ^4.3.4 + checksum: 670858c8f8f3146db5889e1fa117630910101db601fff7d5a8aa637da0abedf68c899f03d3451cac2f83bcc4c3d2dabf339b3aa00ff8080571cceb02c3ce02f3 + languageName: node + linkType: hard + "http2-wrapper@npm:^2.1.10": version: 2.2.1 resolution: "http2-wrapper@npm:2.2.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fhttp2-wrapper%2F-%2Fhttp2-wrapper-2.2.1.tgz" @@ -7143,6 +7403,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.6": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: ^7.1.2 + debug: 4 + checksum: b882377a120aa0544846172e5db021fa8afbf83fea2a897d397bd2ddd8095ab268c24bc462f40a15f2a8c600bf4aa05ce52927f70038d4014e68aefecfa94e8d + languageName: node + linkType: hard + "human-signals@npm:^1.1.1": version: 1.1.1 resolution: "human-signals@npm:1.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fhuman-signals%2F-%2Fhuman-signals-1.1.1.tgz" @@ -7348,6 +7618,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.1.1": + version: 10.2.0 + resolution: "ip-address@npm:10.2.0" + checksum: 3ffba04dc4cdaf81ed2ed6edc47eee1494bb97550ef73f1918ca28405d175c03efa416b8337e868123b08c2cc677e3a07c5ce03eda3b1aeb2741c149bd37ddf9 + languageName: node + linkType: hard + "ip@npm:^1.1.5, ip@npm:^1.1.8": version: 1.1.9 resolution: "ip@npm:1.1.9" @@ -9681,6 +9958,13 @@ __metadata: languageName: node linkType: hard +"mitt@npm:3.0.1": + version: 3.0.1 + resolution: "mitt@npm:3.0.1" + checksum: b55a489ac9c2949ab166b7f060601d3b6d893a852515ae9eca4e11df01c013876df777ea109317622b5c1c60e8aae252558e33c8c94e14124db38f64a39614b1 + languageName: node + linkType: hard + "mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fmkdirp%2F-%2Fmkdirp-0.5.6.tgz" @@ -9722,9 +10006,9 @@ __metadata: languageName: node linkType: hard -"ms@npm:2.1.3": +"ms@npm:2.1.3, ms@npm:^2.1.3": version: 2.1.3 - resolution: "ms@npm:2.1.3::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fms%2F-%2Fms-2.1.3.tgz" + resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d languageName: node linkType: hard @@ -10312,6 +10596,22 @@ __metadata: languageName: node linkType: hard +"pac-proxy-agent@npm:^7.1.0": + version: 7.2.0 + resolution: "pac-proxy-agent@npm:7.2.0" + dependencies: + "@tootallnate/quickjs-emscripten": ^0.23.0 + agent-base: ^7.1.2 + debug: ^4.3.4 + get-uri: ^6.0.1 + http-proxy-agent: ^7.0.0 + https-proxy-agent: ^7.0.6 + pac-resolver: ^7.0.1 + socks-proxy-agent: ^8.0.5 + checksum: 099c1bc8944da6a98e8b7de1fbf23e4014bc3063f66a7c29478bd852c1162e1d086a4f80f874f40961ebd5c516e736aed25852db97b79360cbdcc9db38086981 + languageName: node + linkType: hard + "pac-resolver@npm:^6.0.1": version: 6.0.2 resolution: "pac-resolver@npm:6.0.2::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fpac-resolver%2F-%2Fpac-resolver-6.0.2.tgz" @@ -10323,6 +10623,16 @@ __metadata: languageName: node linkType: hard +"pac-resolver@npm:^7.0.1": + version: 7.0.1 + resolution: "pac-resolver@npm:7.0.1" + dependencies: + degenerator: ^5.0.0 + netmask: ^2.0.2 + checksum: 839134328781b80d49f9684eae1f5c74f50a1d4482076d44c84fc2f3ca93da66fa11245a4725a057231e06b311c20c989fd0681e662a0792d17f644d8fe62a5e + languageName: node + linkType: hard + "package-json@npm:^8.1.0": version: 8.1.1 resolution: "package-json@npm:8.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fpackage-json%2F-%2Fpackage-json-8.1.1.tgz" @@ -10459,6 +10769,13 @@ __metadata: languageName: node linkType: hard +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fpicocolors%2F-%2Fpicocolors-1.0.0.tgz" @@ -10618,6 +10935,13 @@ __metadata: languageName: node linkType: hard +"progress@npm:^2.0.3": + version: 2.0.3 + resolution: "progress@npm:2.0.3" + checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fpromise-retry%2F-%2Fpromise-retry-2.0.1.tgz" @@ -10702,6 +11026,22 @@ __metadata: languageName: node linkType: hard +"proxy-agent@npm:^6.4.0": + version: 6.5.0 + resolution: "proxy-agent@npm:6.5.0" + dependencies: + agent-base: ^7.1.2 + debug: ^4.3.4 + http-proxy-agent: ^7.0.1 + https-proxy-agent: ^7.0.6 + lru-cache: ^7.14.1 + pac-proxy-agent: ^7.1.0 + proxy-from-env: ^1.1.0 + socks-proxy-agent: ^8.0.5 + checksum: d03ad2d171c2768280ade7ea6a7c5b1d0746215d70c0a16e02780c26e1d347edd27b3f48374661ae54ec0f7b41e6e45175b687baf333b36b1fd109a525154806 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fproxy-from-env%2F-%2Fproxy-from-env-1.1.0.tgz" @@ -10735,6 +11075,19 @@ __metadata: languageName: node linkType: hard +"puppeteer-core@npm:^22.15.0": + version: 22.15.0 + resolution: "puppeteer-core@npm:22.15.0" + dependencies: + "@puppeteer/browsers": 2.3.0 + chromium-bidi: 0.6.3 + debug: ^4.3.6 + devtools-protocol: 0.0.1312386 + ws: ^8.18.0 + checksum: 68dbc590275d3d2a231bddf6e53c1e352724d159563abe6b6dc8bcff895476e6dc05bdd1bd2ac969c2970ba8aca2adb48128abd50940e701195bc0e655671696 + languageName: node + linkType: hard + "q@npm:^1.5.1": version: 1.5.1 resolution: "q@npm:1.5.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fq%2F-%2Fq-1.5.1.tgz" @@ -10861,6 +11214,7 @@ __metadata: jest: ^28.1.1 pod-install: ^0.1.0 prettier: ^2.0.5 + puppeteer-core: ^22.15.0 react: 18.2.0 react-native: 0.73.2 react-native-builder-bob: ^0.20.0 @@ -11636,6 +11990,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 0c580f17e88e2b45806dc5cf3d824f719c946999d3554bf30307c2b68b3300ab3c8bfcd84d8f489b93b4cbb5362f5fc8de4d2858954f18d834253c4450fc9f6b + languageName: node + linkType: hard + "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -11834,6 +12197,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^8.0.5": + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" + dependencies: + agent-base: ^7.1.2 + debug: ^4.3.4 + socks: ^2.8.3 + checksum: b4fbcdb7ad2d6eec445926e255a1fb95c975db0020543fbac8dfa6c47aecc6b3b619b7fb9c60a3f82c9b2969912a5e7e174a056ae4d98cb5322f3524d6036e1d + languageName: node + linkType: hard + "socks@npm:^2.7.1": version: 2.7.1 resolution: "socks@npm:2.7.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fsocks%2F-%2Fsocks-2.7.1.tgz" @@ -11844,6 +12218,16 @@ __metadata: languageName: node linkType: hard +"socks@npm:^2.8.3": + version: 2.8.9 + resolution: "socks@npm:2.8.9" + dependencies: + ip-address: ^10.1.1 + smart-buffer: ^4.2.0 + checksum: b573ed4cfb935624d3688e7065cd03fd72ca258156923c9ebb9d462e545cd78f296b64a0e36f911b16326c94aabe2bf94ff405f8afef27ac7bf80fa3c971c6f6 + languageName: node + linkType: hard + "source-map-support@npm:0.5.13": version: 0.5.13 resolution: "source-map-support@npm:0.5.13::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fsource-map-support%2F-%2Fsource-map-support-0.5.13.tgz" @@ -12010,6 +12394,17 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": + version: 2.28.0 + resolution: "streamx@npm:2.28.0" + dependencies: + events-universal: ^1.0.0 + fast-fifo: ^1.3.2 + text-decoder: ^1.1.0 + checksum: c04570e5dd927670886dcbcff103c41a8cc717a7ac7e682788975a6eee3f3906ad9feb402020bd44ea21c1494f77139344f4bcb944f4b3a9ddb20307943015bc + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fstring-length%2F-%2Fstring-length-4.0.2.tgz" @@ -12262,6 +12657,35 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^3.0.6": + version: 3.1.3 + resolution: "tar-fs@npm:3.1.3" + dependencies: + bare-fs: ^4.0.1 + bare-path: ^3.0.0 + pump: ^3.0.0 + tar-stream: ^3.1.5 + dependenciesMeta: + bare-fs: + optional: true + bare-path: + optional: true + checksum: f605987970d75feeba5c05209c19789227fcbcae90a6acfc2825304b181234940093b304a864924e1958af2b060459e109db5470e018e5eb1f003069eb68bc6f + languageName: node + linkType: hard + +"tar-stream@npm:^3.1.5": + version: 3.2.0 + resolution: "tar-stream@npm:3.2.0" + dependencies: + b4a: ^1.6.4 + bare-fs: ^4.5.5 + fast-fifo: ^1.2.0 + streamx: ^2.15.0 + checksum: 975947de3aeb85a66b729fb38682c47738f38df331897d0702cbf55ed6eff4b39d942783279ffa134b247cf7f7e2210c0fe4324c581a40ea4a1ebc6989d24560 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -12276,6 +12700,15 @@ __metadata: languageName: node linkType: hard +"teex@npm:^1.0.1": + version: 1.0.1 + resolution: "teex@npm:1.0.1" + dependencies: + streamx: ^2.12.5 + checksum: 36bf7ce8bb5eb428ad7b14b695ee7fb0a02f09c1a9d8181cc42531208543a920b299d711bf78dad4ff9bcf36ac437ae8e138053734746076e3e0e7d6d76eef64 + languageName: node + linkType: hard + "temp-dir@npm:^2.0.0": version: 2.0.0 resolution: "temp-dir@npm:2.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ftemp-dir%2F-%2Ftemp-dir-2.0.0.tgz" @@ -12327,6 +12760,15 @@ __metadata: languageName: node linkType: hard +"text-decoder@npm:^1.1.0": + version: 1.2.7 + resolution: "text-decoder@npm:1.2.7" + dependencies: + b4a: ^1.6.4 + checksum: a544f8490806675986e9703cda2d0809d7bd010adf0cc19ac9975791912ea9e6998cd4696d7d7e9392c5648e660111a865c983e8adfeb29699fab471548f82a3 + languageName: node + linkType: hard + "text-extensions@npm:^1.0.0": version: 1.9.0 resolution: "text-extensions@npm:1.9.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ftext-extensions%2F-%2Ftext-extensions-1.9.0.tgz" @@ -12374,6 +12816,13 @@ __metadata: languageName: node linkType: hard +"through@npm:^2.3.8": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd + languageName: node + linkType: hard + "titleize@npm:^3.0.0": version: 3.0.0 resolution: "titleize@npm:3.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Ftitleize%2F-%2Ftitleize-3.0.0.tgz" @@ -12760,6 +13209,16 @@ __metadata: languageName: node linkType: hard +"unbzip2-stream@npm:^1.4.3": + version: 1.4.3 + resolution: "unbzip2-stream@npm:1.4.3" + dependencies: + buffer: ^5.2.1 + through: ^2.3.8 + checksum: 0e67c4a91f4fa0fc7b4045f8b914d3498c2fc2e8c39c359977708ec85ac6d6029840e97f508675fdbdf21fcb8d276ca502043406f3682b70f075e69aae626d1d + languageName: node + linkType: hard + "unc-path-regex@npm:^0.1.2": version: 0.1.2 resolution: "unc-path-regex@npm:0.1.2::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Func-path-regex%2F-%2Func-path-regex-0.1.2.tgz" @@ -12919,6 +13378,13 @@ __metadata: languageName: node linkType: hard +"urlpattern-polyfill@npm:10.0.0": + version: 10.0.0 + resolution: "urlpattern-polyfill@npm:10.0.0" + checksum: 61d890f151ea4ecf34a3dcab32c65ad1f3cda857c9d154af198260c6e5b2ad96d024593409baaa6d4428dd1ab206c14799bf37fe011117ac93a6a44913ac5aa4 + languageName: node + linkType: hard + "utf8@npm:^3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Futf8%2F-%2Futf8-3.0.0.tgz" @@ -13266,6 +13732,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.21.1 + resolution: "ws@npm:8.21.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 2e18c932d7bfeeb36fae32d874e5531f501e26dc932c48b699f9fcd2d7cc38f365e0265eddb47dec413ac6fb0effc0a8fa2cfa372d23f4f5b5ab214f394e1774 + languageName: node + linkType: hard + "xdg-basedir@npm:^5.0.1, xdg-basedir@npm:^5.1.0": version: 5.1.0 resolution: "xdg-basedir@npm:5.1.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fxdg-basedir%2F-%2Fxdg-basedir-5.1.0.tgz" @@ -13395,6 +13876,31 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.7.2": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: ^8.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.3 + y18n: ^5.0.5 + yargs-parser: ^21.1.1 + checksum: 16fb2d4866f04aaf74f206d3df843e7a80b58a26fda47af29be51b27564c19966b1674c3acf679a26fdfa8a7e4b1b442a63cdab5db8cd3c57db7912f4473e343 + languageName: node + linkType: hard + +"yauzl@npm:^2.10.0": + version: 2.10.0 + resolution: "yauzl@npm:2.10.0" + dependencies: + buffer-crc32: ~0.2.3 + fd-slicer: ~1.1.0 + checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fyn%2F-%2Fyn-3.1.1.tgz" @@ -13408,3 +13914,10 @@ __metadata: checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"zod@npm:3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 15949ff82118f59c893dacd9d3c766d02b6fa2e71cf474d5aa888570c469dbf5446ac5ad562bb035bf7ac9650da94f290655c194f4a6de3e766f43febd432c5c + languageName: node + linkType: hard