From 436240964306b79d58286384c171fce026067ca9 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Tue, 31 Mar 2026 15:12:15 +0800 Subject: [PATCH 1/9] feat(rn): add new architecture support --- README.md | 2 + android/build.gradle | 18 ++- android/cpp-adapter.cpp | 30 +++-- .../bsdiffpatch/BsDiffPatchModule.kt | 43 +++++++ .../bsdiffpatch/BsDiffPatchPackage.kt | 36 ++++++ .../bsdiffpatch/BsDiffPatchModule.kt | 52 +++++++++ .../bsdiffpatch/BsDiffPatchPackage.kt | 16 +++ .../bsdiffpatch/BsDiffPatchModule.kt | 107 ------------------ .../bsdiffpatch/BsDiffPatchNative.kt | 95 ++++++++++++++++ .../bsdiffpatch/BsDiffPatchPackage.kt | 18 --- ios/BsDiffPatch.mm | 13 +++ package.json | 8 ++ src/NativeBsDiffPatch.ts | 9 ++ src/index.ts | 19 +--- 14 files changed, 310 insertions(+), 156 deletions(-) create mode 100644 android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt create mode 100644 android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt create mode 100644 android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt create mode 100644 android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt delete mode 100644 android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt create mode 100644 android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt delete mode 100644 android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt create mode 100644 src/NativeBsDiffPatch.ts diff --git a/README.md b/README.md index 7b50269..654b078 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ rn bs diff patch file +Supports both the legacy bridge and React Native New Architecture (TurboModule). + ## Installation ```sh diff --git a/android/build.gradle b/android/build.gradle index 475489d..5982770 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,6 +20,12 @@ 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 +48,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"] + : ["src/main/java", "oldarch/java"] + ) } } @@ -100,4 +113,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..49c80e6 --- /dev/null +++ b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -0,0 +1,43 @@ +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) { + 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) { + try { + promise.resolve(block()) + } catch (error: BsDiffPatchException) { + promise.reject(error.code, error.message, error) + } catch (error: Exception) { + promise.reject("EUNSPECIFIED", error.message, error) + } + } +} diff --git a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt new file mode 100644 index 0000000..b3dc767 --- /dev/null +++ b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt @@ -0,0 +1,36 @@ +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, + 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..e1fed04 --- /dev/null +++ b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -0,0 +1,52 @@ +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) { + 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) { + try { + promise.resolve(block()) + } catch (error: BsDiffPatchException) { + promise.reject(error.code, error.message, error) + } catch (error: Exception) { + promise.reject("EUNSPECIFIED", error.message, error) + } + } +} 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/ios/BsDiffPatch.mm b/ios/BsDiffPatch.mm index cc6886c..eb6560d 100644 --- a/ios/BsDiffPatch.mm +++ b/ios/BsDiffPatch.mm @@ -1,5 +1,10 @@ #import "BsDiffPatch.h" +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#endif + @implementation BsDiffPatch RCT_EXPORT_MODULE() @@ -89,4 +94,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..1657254 100644 --- a/package.json +++ b/package.json @@ -161,5 +161,13 @@ } ] ] + }, + "codegenConfig": { + "name": "RNBsDiffPatchSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.jimmydaddy.bsdiffpatch" + } } } 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/index.ts b/src/index.ts index b74e1c1..e00b0f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,4 @@ -import { NativeModules, Platform } from 'react-native'; - -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); - }, - } - ); +import BsDiffPatch from './NativeBsDiffPatch'; /** * generate new file from old file and patch file From 3d1278785a46acaa3362a13e2f22e4863cc074e0 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sat, 18 Jul 2026 22:49:33 +0800 Subject: [PATCH 2/9] feat: add cross-platform support and documentation site --- .github/workflows/ci.yml | 489 +++--- .github/workflows/pages.yml | 74 + .gitignore | 1 + CONTRIBUTING.md | 23 + README.md | 95 +- README.zh-CN.md | 91 + android/build.gradle | 29 +- .../bsdiffpatch/BsDiffPatchModule.kt | 15 +- .../bsdiffpatch/BsDiffPatchPackage.kt | 1 - .../bsdiffpatch/BsDiffPatchPackage.kt | 35 + .../bsdiffpatch/BsDiffPatchModule.kt | 15 +- .../bsdiffpatch/BsDiffPatchTaskRunner.kt | 31 + docs/README.md | 16 + docs/api-reference.md | 91 + docs/architecture.md | 70 + docs/development.md | 78 + docs/getting-started.md | 95 ++ docs/platform-support.md | 60 + ...tive-new-architecture-review-2026-07-18.md | 101 ++ docs/troubleshooting.md | 64 + example/android/app/build.gradle | 6 + .../NewArchitectureRuntimeTest.kt | 56 + example/src/App.tsx | 82 +- ios/BsDiffPatch.mm | 15 + package.json | 25 +- scripts/build-site.mjs | 368 +++++ scripts/build-web-wasm.sh | 39 + scripts/test-site-browser.mjs | 122 ++ scripts/test-site.mjs | 107 ++ scripts/test-web-browser.mjs | 97 ++ scripts/test-web-metro.mjs | 56 + scripts/test-web.mjs | 32 + scripts/web-metro-entry.js | 1 + scripts/web-test.html | 75 + site/404.html | 35 + site/CNAME | 1 + site/assets/playground.js | 115 ++ site/assets/site.css | 1467 +++++++++++++++++ site/assets/site.js | 61 + site/index.html | 319 ++++ site/robots.txt | 4 + site/sitemap.xml | 11 + src/__tests__/index.test.tsx | 42 +- src/index.ts | 30 + src/index.web.ts | 2 + tsconfig.build.json | 2 +- tsconfig.json | 3 +- web/bsdiffpatch.mjs | Bin 0 -> 162119 bytes web/index.d.mts | 23 + web/index.mjs | 129 ++ web/operations.mjs | 70 + web/worker.mjs | 21 + yarn.lock | 520 ++++++ 53 files changed, 5127 insertions(+), 283 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 README.zh-CN.md rename android/{newarch => newarch73}/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt (98%) create mode 100644 android/newarch74/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt create mode 100644 android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt create mode 100644 docs/README.md create mode 100644 docs/api-reference.md create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/getting-started.md create mode 100644 docs/platform-support.md create mode 100644 docs/review/resolved_react-native-new-architecture-review-2026-07-18.md create mode 100644 docs/troubleshooting.md create mode 100644 example/android/app/src/androidTest/java/com/bsdiffpatchexample/NewArchitectureRuntimeTest.kt create mode 100644 scripts/build-site.mjs create mode 100644 scripts/build-web-wasm.sh create mode 100644 scripts/test-site-browser.mjs create mode 100644 scripts/test-site.mjs create mode 100644 scripts/test-web-browser.mjs create mode 100644 scripts/test-web-metro.mjs create mode 100644 scripts/test-web.mjs create mode 100644 scripts/web-metro-entry.js create mode 100644 scripts/web-test.html create mode 100644 site/404.html create mode 100644 site/CNAME create mode 100644 site/assets/playground.js create mode 100644 site/assets/site.css create mode 100644 site/assets/site.js create mode 100644 site/index.html create mode 100644 site/robots.txt create mode 100644 site/sitemap.xml create mode 100644 src/index.web.ts create mode 100644 web/bsdiffpatch.mjs create mode 100644 web/index.d.mts create mode 100644 web/index.mjs create mode 100644 web/operations.mjs create mode 100644 web/worker.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e231f86..01ddf90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,149 +5,154 @@ on: workflow_dispatch: jobs: - 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 + - 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@v4 + 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@v4 + 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 android-build: runs-on: macos-latest - name: Android Build + name: Android Build (newArch=${{ matrix.new-arch }}) needs: install-dep + strategy: + fail-fast: false + matrix: + new-arch: [false, true] 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@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/android/** + example/src/** + !example/ios/** + example/e2e/** + + - uses: actions/cache@v4 + 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@v4 + 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-${{ matrix.new-arch }}-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/**/*.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@v4 + if: steps.verify-android-changed-files.outputs.any_changed == 'true' + with: + node-version: '18' + + - name: Set up JDK + uses: actions/setup-java@v4 + 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 -PnewArchEnabled=${{ matrix.new-arch }} + + - name: Run unit tests + if: steps.verify-android-changed-files.outputs.any_changed == 'true' + run: | + cd example/android + ./gradlew test --stacktrace -PnewArchEnabled=${{ matrix.new-arch }} + + - name: Build APK + if: steps.verify-android-changed-files.outputs.any_changed == 'true' + run: | + npm run prepare + cd example/android + ./gradlew assembleRelease -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 APK + if: steps.verify-android-changed-files.outputs.any_changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk + path: ${{ github.workspace }}/example/android/app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk android-api-level-test: runs-on: macos-latest needs: android-build - name: Android Test + name: Android New Architecture Runtime Test (API ${{ matrix.api-level }}) strategy: + fail-fast: false matrix: api-level: [24, 25, 29, 30, 31] target: [default] @@ -164,10 +169,12 @@ jobs: src/** assets/** package.json + example/android/** + example/src/** !example/ios/** example/e2e/** - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Cache node_modules if: steps.verify-android-changed-files.outputs.any_changed == 'true' id: cache-node-modules @@ -178,7 +185,7 @@ jobs: fail-on-cache-miss: true key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Cache Gradle dependencies if: steps.verify-android-changed-files.outputs.any_changed == 'true' id: cache-gradle @@ -187,7 +194,7 @@ jobs: ~/.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') }} + key: ${{ runner.os }}-gradle-new-arch-runtime-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/**/*.kt', 'example/android/**/*.kt', 'example/android/**/*.gradle') }} - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -197,13 +204,13 @@ jobs: bundler-cache: true - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 if: steps.verify-android-changed-files.outputs.any_changed == 'true' with: node-version: '18' - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 if: steps.verify-android-changed-files.outputs.any_changed == 'true' with: distribution: 'zulu' @@ -218,100 +225,158 @@ jobs: arch: x86_64 profile: Nexus 6 script: | - cd example/android && ./gradlew connectedCheck --stacktrace + cd example/android && ./gradlew :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 - name: Upload Reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: steps.verify-android-changed-files.outputs.any_changed == 'true' with: - name: Test-Reports - path: ${{ github.workspace }}/example/android/app/build/reports + name: New-Arch-Runtime-Test-Reports-${{ matrix.api-level }}-${{ matrix.target }} + path: ${{ github.workspace }}/example/android/app/build/reports ios-build-test: runs-on: macos-latest needs: install-dep name: iOS Build and Test strategy: + fail-fast: false matrix: cocoapods: ['1.10.1', '1.11.0', '1.14.3'] + new-arch: ['0', '1'] 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@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@v4 + 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@v4 + if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' + with: + path: example/ios/Pods + key: ${{ runner.os }}-pods-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}-${{ 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@v4 + 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 + RCT_NEW_ARCH_ENABLED=${{ matrix.new-arch }} 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 BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | xcpretty + + - name: Test + if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' + run: | + cd example/ios + xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test | xcpretty + + web-test: + runs-on: macos-latest + needs: install-dep + name: Web WASM and Browser Test + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Verify Web Changed files + uses: tj-actions/changed-files@v46 + id: verify-web-changed-files + with: + files: | + cpp/** + src/** + web/** + scripts/build-web-wasm.sh + scripts/test-web.mjs + scripts/test-web-browser.mjs + scripts/test-web-metro.mjs + scripts/web-metro-entry.js + scripts/web-test.html + package.json + yarn.lock + + - uses: actions/cache@v4 + name: Cache node_modules + if: steps.verify-web-changed-files.outputs.any_changed == 'true' + with: + path: | + node_modules + example/node_modules + fail-on-cache-miss: true + key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} + + - name: Setup node + if: steps.verify-web-changed-files.outputs.any_changed == 'true' + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Build package + if: steps.verify-web-changed-files.outputs.any_changed == 'true' + run: yarn prepare + + - name: Run Web tests + if: steps.verify-web-changed-files.outputs.any_changed == 'true' + run: | + yarn test:web + yarn test:web:browser + yarn test:web:metro + + - name: Verify npm package contents + if: steps.verify-web-changed-files.outputs.any_changed == 'true' + run: npm pack --dry-run --ignore-scripts ci-complete: name: Complete CI - needs: [android-build, android-api-level-test, ios-build-test] + needs: [android-build, android-api-level-test, ios-build-test, web-test] if: ${{ always() }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..4515d5e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,74 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main] + 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@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + 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@v5 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@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@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..34353c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,22 @@ 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 +``` + ### Commit message convention We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: @@ -108,6 +124,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 654b078..9af27fd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,21 @@ # react-native-bs-diff-patch -rn bs diff patch file +Create and apply compact binary patches on Android, iOS, and React Native Web. +The native platforms use the bundled C implementation through JNI/Objective-C++; +Web uses the same implementation compiled to WebAssembly and isolated in a +Web Worker. -Supports both the legacy bridge and React Native New Architecture (TurboModule). +[Documentation](https://bs-dff-patch.corerobin.com/docs/) · +[Playground](https://bs-dff-patch.corerobin.com/#playground) · +[中文说明](./README.zh-CN.md) + +## Features + +- One `ENDSLEY/BSDIFF43` patch format across Android, iOS, and Web. +- React Native legacy architecture and TurboModule/New Architecture support. +- Dedicated native worker queues and an isolated Web Worker for expensive work. +- File-path APIs on native and typed binary APIs in the browser. +- TypeScript declarations and deterministic WebAssembly/browser tests. ## Installation @@ -10,30 +23,80 @@ Supports both the legacy bridge and React Native New Architecture (TurboModule). npm install react-native-bs-diff-patch ``` -## Usage +For iOS, install pods after adding the package: + +```sh +npx pod-install +``` + +## Native quick start -```js +```ts import { diff, patch } from 'react-native-bs-diff-patch'; -// ... +const patchPath = `${cacheDirectory}/update.patch`; +const restoredPath = `${cacheDirectory}/restored.bin`; + +await diff(oldFilePath, newFilePath, patchPath); +await patch(oldFilePath, restoredPath, patchPath); +``` + +`diff` expects the old and new files to exist and the patch path to be unused. +`patch` expects the old file and patch to exist and the output path to be unused. +Both resolve to `0` on success. + +## React Native Web quick start + +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; -/** - * 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); +const oldData = await oldFile.arrayBuffer(); +const newData = await newFile.arrayBuffer(); +const patchData = await diffBytes(oldData, newData); +const restoredData = await patchBytes(oldData, patchData); ``` +`diffBytes` and `patchBytes` accept `ArrayBuffer`, any `ArrayBufferView` +(including typed arrays and `DataView`), or `Blob`, and resolve to a +`Uint8Array`. + +## 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 | + +The unsupported API family rejects with `EUNSUPPORTED`, making accidental +cross-platform use explicit. + +## Documentation + +- [Getting started](./docs/getting-started.md) +- [API reference](./docs/api-reference.md) +- [Platform support](./docs/platform-support.md) +- [Architecture and patch format](./docs/architecture.md) +- [Troubleshooting](./docs/troubleshooting.md) +- [Development and verification](./docs/development.md) + +## Security and resource limits + +Binary diffing is CPU- and memory-intensive. Native work runs off the React +Native module queue and Web work runs in a Worker, but applications should still +apply file-size, time, and trust-boundary limits appropriate to their product. +Validate patch provenance before applying patches received from an untrusted +source. + ## 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..ffb99f8 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,91 @@ +# react-native-bs-diff-patch + +在 Android、iOS 和 React Native Web 上生成与应用紧凑的二进制补丁。原生端通过 +JNI / Objective-C++ 调用项目内置的 C 实现;Web 端将同一套实现编译为 +WebAssembly,并在独立 Web Worker 中执行。 + +[在线文档](https://bs-dff-patch.corerobin.com/docs/) · +[在线 Playground](https://bs-dff-patch.corerobin.com/#playground) · +[English](./README.md) + +## 能力概览 + +- Android、iOS、Web 共用 `ENDSLEY/BSDIFF43` 补丁格式。 +- 支持 React Native 旧架构与 TurboModule / 新架构。 +- 原生端使用专用串行工作队列,Web 端使用 Web Worker,避免阻塞 UI。 +- 原生端提供文件路径 API,Web 端提供强类型二进制 API。 +- 提供 TypeScript 类型、WASM 往返测试、真实浏览器测试和 Metro Web 入口测试。 + +## 安装 + +```sh +npm install react-native-bs-diff-patch +``` + +iOS 项目安装依赖后还需要执行: + +```sh +npx pod-install +``` + +## 原生端用法 + +```ts +import { diff, patch } from 'react-native-bs-diff-patch'; + +const patchPath = `${cacheDirectory}/update.patch`; +const restoredPath = `${cacheDirectory}/restored.bin`; + +await diff(oldFilePath, newFilePath, patchPath); +await patch(oldFilePath, restoredPath, patchPath); +``` + +`diff` 要求旧文件和新文件已经存在,补丁路径尚未创建;`patch` 要求旧文件和补丁 +已经存在,输出路径尚未创建。成功时均返回 `0`。 + +## React Native Web 用法 + +```ts +import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; + +const oldData = await oldFile.arrayBuffer(); +const newData = await newFile.arrayBuffer(); + +const patchData = await diffBytes(oldData, newData); +const restoredData = await patchBytes(oldData, patchData); +``` + +`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` 拒绝。 + +## 完整文档 + +- [快速开始](./docs/getting-started.md) +- [API 参考](./docs/api-reference.md) +- [平台支持](./docs/platform-support.md) +- [架构与补丁格式](./docs/architecture.md) +- [常见问题与排障](./docs/troubleshooting.md) +- [开发与验证](./docs/development.md) + +## 资源与安全边界 + +二进制差分会消耗较多 CPU 和内存。虽然本库已经把原生计算移出 React Native 模块 +队列,并在 Web 端使用 Worker,业务仍应按自身场景限制文件大小、执行时间和输入 +来源。应用来自不可信来源的补丁前,应先验证补丁的来源与完整性。 + +## License + +MIT diff --git a/android/build.gradle b/android/build.gradle index 5982770..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,6 +17,31 @@ 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" @@ -57,7 +84,7 @@ android { } java.setSrcDirs( isNewArchitectureEnabled() - ? ["src/main/java", "newarch/java"] + ? ["src/main/java", "newarch/java", getNewArchitecturePackageSourceDir()] : ["src/main/java", "oldarch/java"] ) } diff --git a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt index 49c80e6..0006c00 100644 --- a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt +++ b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -7,6 +7,8 @@ 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( @@ -32,12 +34,11 @@ class BsDiffPatchModule(reactContext: ReactApplicationContext) : } private fun execute(promise: Promise, block: () -> Int) { - try { - promise.resolve(block()) - } catch (error: BsDiffPatchException) { - promise.reject(error.code, error.message, error) - } catch (error: Exception) { - promise.reject("EUNSPECIFIED", error.message, error) - } + taskRunner.execute(promise, block) + } + + override fun invalidate() { + taskRunner.shutdown() + super.invalidate() } } diff --git a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt b/android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt similarity index 98% rename from android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt rename to android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt index b3dc767..76e17c8 100644 --- a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt +++ b/android/newarch73/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchPackage.kt @@ -27,7 +27,6 @@ class BsDiffPatchPackage : TurboReactPackage() { false, 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 index e1fed04..0d04773 100644 --- a/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt +++ b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt @@ -9,6 +9,8 @@ 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 @@ -41,12 +43,11 @@ class BsDiffPatchModule(reactContext: ReactApplicationContext) : } private fun execute(promise: Promise, block: () -> Int) { - try { - promise.resolve(block()) - } catch (error: BsDiffPatchException) { - promise.reject(error.code, error.message, error) - } catch (error: Exception) { - promise.reject("EUNSPECIFIED", error.message, error) - } + taskRunner.execute(promise, block) + } + + override fun invalidate() { + taskRunner.shutdown() + super.invalidate() } } 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..4d9cda6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# Documentation + +This directory is the source of truth for the public documentation rendered at +[bs-dff-patch.corerobin.com/docs](https://bs-dff-patch.corerobin.com/docs/). + +## Guides + +- [Getting started](./getting-started.md) — installation and first native/Web patch. +- [API reference](./api-reference.md) — signatures, inputs, outputs, and errors. +- [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..f6e4d7b --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,91 @@ +# API reference + +The package exposes two platform-specific API families under one import path. + +## `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. + +## `patch` + +```ts +function patch( + oldFile: string, + newFile: string, + patchFile: string +): Promise; +``` + +Reconstructs the target file at `newFile`. Available on Android and iOS. + +- `oldFile`: existing baseline file path. +- `newFile`: destination path that must not already exist. +- `patchFile`: existing compatible patch path. +- Resolves to `0` on success. + +## `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. + +## `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. + +## Error shape + +Rejected operations expose a normal `Error` with a string `code` when the +platform can classify the failure. + +| 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. + +## 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. + +## 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..0eaf7ba --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,70 @@ +# 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. + +## 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. + +## 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..528b591 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,78 @@ +# 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. + +## 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 builds and tests legacy and New +Architecture configurations across the supported CocoaPods matrix. + +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. Use the repository release command to create the version and tag. +5. Verify the npm package and GitHub release before announcing availability. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..b9b9d2a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,95 @@ +# Getting started + +This guide creates a patch, applies it, and verifies the restored output. + +## Install + +```sh +npm install react-native-bs-diff-patch +``` + +Install iOS pods after changing native dependencies: + +```sh +npx pod-install +``` + +No manual Android package registration is required when React Native +autolinking is enabled. + +## Native file workflow + +Native APIs operate on absolute file paths. A filesystem library or your own +native code is responsible for creating and reading those files. + +```ts +import { diff, patch } from 'react-native-bs-diff-patch'; + +const patchPath = `${cacheDirectory}/release-2.patch`; +const restoredPath = `${cacheDirectory}/release-2.restored`; + +const diffResult = await diff(oldPath, newPath, patchPath); +const patchResult = await patch(oldPath, restoredPath, patchPath); + +if (diffResult !== 0 || patchResult !== 0) { + throw new Error('Binary patch operation failed'); +} +``` + +Before calling `diff`: + +- `oldPath` and `newPath` must exist. +- `patchPath` must not exist. +- All three paths must be different. + +Before calling `patch`: + +- `oldPath` and `patchPath` must exist. +- `restoredPath` must not exist. +- All three paths must be different. + +Remove stale outputs or choose unique names before retrying an operation. + +## Web binary workflow + +React Native Web uses binary data 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]); +``` + +Inputs are copied before being transferred to the worker, so the caller's +buffers remain usable. The result is a new `Uint8Array`. + +## Use files in a browser + +```ts +const oldData = await oldFile.arrayBuffer(); +const newData = await newFile.arrayBuffer(); +const patchData = await diffBytes(oldData, newData); + +const download = document.createElement('a'); +download.href = URL.createObjectURL( + new Blob([patchData], { type: 'application/octet-stream' }) +); +download.download = 'update.patch'; +download.click(); +``` + +## Next steps + +- Read the [API reference](./api-reference.md). +- Check [platform and bundler support](./platform-support.md). +- Understand the [execution architecture](./architecture.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..79267f7 --- /dev/null +++ b/docs/platform-support.md @@ -0,0 +1,60 @@ +# 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 project is continuously exercised with React Native 0.73 and has +compatibility build coverage against newer New Architecture package APIs. + +## 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. + +## 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. 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..6537f1f --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,64 @@ +# Troubleshooting + +## `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. + +## `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. + +## 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/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/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 eb6560d..3649814 100644 --- a/ios/BsDiffPatch.mm +++ b/ios/BsDiffPatch.mm @@ -8,6 +8,21 @@ @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 diff --git a/package.json b/package.json index 1657254..5c0573c 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", @@ -138,6 +155,7 @@ } }, "eslintIgnore": [ + "build/", "node_modules/", "lib/" ], @@ -168,6 +186,11 @@ "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..dd4decf --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,368 @@ +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: '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', + }, +]; + +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 + .toLowerCase() + .replace(/`/g, '') + .replace(/[^a-z0-9]+/g, '-') + .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'); +} + +function navigation(currentSlug) { + return [ + { slug: '', title: 'Documentation home' }, + ...pages.map(({ slug, title }) => ({ slug, title })), + ] + .map(({ slug, title }) => { + const href = slug ? `/docs/${slug}/` : '/docs/'; + const current = slug === currentSlug ? ' aria-current="page"' : ''; + return `${escapeHtml(title)}`; + }) + .join('\n'); +} + +function documentationLayout({ slug, title, description, content }) { + const canonical = slug ? `/docs/${slug}/` : '/docs/'; + return ` + + + + + + + + + + + + ${escapeHtml(title)} — react-native-bs-diff-patch + + + + + +
    + +
    +
    +

    Docs / ${escapeHtml(title)}

    +

    ${escapeHtml(title)}

    +

    ${escapeHtml(description)}

    +
    +
    ${content}
    +
    +
    +
    + +

    MIT licensed. Built for React Native runtimes.

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

    ${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(), + }) +); + +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), + }) + ); +} + +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/test-site-browser.mjs b/scripts/test-site-browser.mjs new file mode 100644 index 0000000..b126f0d --- /dev/null +++ b/scripts/test-site-browser.mjs @@ -0,0 +1,122 @@ +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' + ); + assert.equal(pageErrors.length, 0, pageErrors.join('\n')); + console.log('Site Playground, 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..6007f7c --- /dev/null +++ b/scripts/test-site.mjs @@ -0,0 +1,107 @@ +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 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/platform-support/index.html', + 'docs/architecture/index.html', + 'docs/troubleshooting/index.html', + 'docs/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(); +} + +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 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..8e16516 --- /dev/null +++ b/site/assets/site.js @@ -0,0 +1,61 @@ +/* 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 ? 'Menu' : 'Close'; + navLinks.classList.toggle('is-open', !expanded); + }); + + navLinks.addEventListener('click', (event) => { + if (event.target instanceof HTMLAnchorElement) { + navToggle.setAttribute('aria-expanded', 'false'); + navToggle.textContent = '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..6070884 --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,11 @@ + + + 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/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/ + 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 e00b0f9..26b21b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import BsDiffPatch from './NativeBsDiffPatch'; +export type BinaryInput = ArrayBuffer | ArrayBufferView | Blob; + /** * generate new file from old file and patch file * @param oldFile orignal file path @@ -28,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 0000000000000000000000000000000000000000..106150e00e34ba3a2dbb8d79d89378db45519055 GIT binary patch literal 162119 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 z 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 literal 0 HcmV?d00001 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..fcd7641 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" @@ -9729,6 +10013,13 @@ __metadata: languageName: node linkType: hard +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d + languageName: node + linkType: hard + "mute-stream@npm:1.0.0": version: 1.0.0 resolution: "mute-stream@npm:1.0.0::__archiveUrl=https%3A%2F%2Fregistry.npmmirror.com%2Fmute-stream%2F-%2Fmute-stream-1.0.0.tgz" @@ -10312,6 +10603,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 +10630,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 +10776,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 +10942,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 +11033,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 +11082,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 +11221,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 +11997,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 +12204,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 +12225,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 +12401,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 +12664,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 +12707,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 +12767,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 +12823,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 +13216,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 +13385,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 +13739,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 +13883,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 +13921,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 From a99950bcab9923041734748dbf59e0f0c58bfccb Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sat, 18 Jul 2026 22:53:26 +0800 Subject: [PATCH 3/9] chore: enable initial Pages deployment --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 4515d5e..0f36cc8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: Deploy site to GitHub Pages on: push: - branches: [main] + branches: [main, feat/new_arch] paths: - '.github/workflows/pages.yml' - 'docs/**' From 361cc9f874cbcc8f2463842eb7d68111f926cc47 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sat, 18 Jul 2026 22:56:48 +0800 Subject: [PATCH 4/9] fix: normalize Yarn lockfile for Pages --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index fcd7641..238c828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10006,14 +10006,7 @@ __metadata: languageName: node linkType: hard -"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" - checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d - 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" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d From 4fe998c6b6beba8a13bbc58e9e131928807f7e7d Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 00:18:23 +0800 Subject: [PATCH 5/9] docs: improve bilingual documentation --- CONTRIBUTING.md | 4 + README.md | 107 +++++++++++------ README.zh-CN.md | 114 ++++++++++++------ docs/README.md | 18 ++- docs/api-reference.md | 52 ++++++++- docs/architecture.md | 18 +++ docs/development.md | 7 +- docs/getting-started.md | 106 +++++++++++------ docs/platform-support.md | 15 ++- docs/recipes.md | 100 ++++++++++++++++ docs/troubleshooting.md | 24 ++++ docs/zh-CN/README.md | 27 +++++ docs/zh-CN/api-reference.md | 119 +++++++++++++++++++ docs/zh-CN/architecture.md | 77 +++++++++++++ docs/zh-CN/development.md | 79 +++++++++++++ docs/zh-CN/getting-started.md | 126 ++++++++++++++++++++ docs/zh-CN/platform-support.md | 66 +++++++++++ docs/zh-CN/recipes.md | 90 +++++++++++++++ docs/zh-CN/troubleshooting.md | 79 +++++++++++++ scripts/build-site.mjs | 205 ++++++++++++++++++++++++++++----- scripts/test-site-browser.mjs | 15 ++- scripts/test-site.mjs | 53 +++++++++ site/assets/site.js | 6 +- site/sitemap.xml | 9 ++ 24 files changed, 1366 insertions(+), 150 deletions(-) create mode 100644 docs/recipes.md create mode 100644 docs/zh-CN/README.md create mode 100644 docs/zh-CN/api-reference.md create mode 100644 docs/zh-CN/architecture.md create mode 100644 docs/zh-CN/development.md create mode 100644 docs/zh-CN/getting-started.md create mode 100644 docs/zh-CN/platform-support.md create mode 100644 docs/zh-CN/recipes.md create mode 100644 docs/zh-CN/troubleshooting.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34353c4..5f3687c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,10 @@ 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: diff --git a/README.md b/README.md index 9af27fd..07348cb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,26 @@ # react-native-bs-diff-patch -Create and apply compact binary patches on Android, iOS, and React Native Web. -The native platforms use the bundled C implementation through JNI/Objective-C++; -Web uses the same implementation compiled to WebAssembly and isolated in a -Web Worker. +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) +[中文说明](./README.zh-CN.md) · [npm](https://www.npmjs.com/package/react-native-bs-diff-patch) -## Features +## Why use it? -- One `ENDSLEY/BSDIFF43` patch format across Android, iOS, and Web. -- React Native legacy architecture and TurboModule/New Architecture support. -- Dedicated native worker queues and an isolated Web Worker for expensive work. -- File-path APIs on native and typed binary APIs in the browser. -- TypeScript declarations and deterministic WebAssembly/browser tests. +- **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 @@ -23,43 +28,67 @@ Web Worker. npm install react-native-bs-diff-patch ``` -For iOS, install pods after adding the package: +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 +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'; -const patchPath = `${cacheDirectory}/update.patch`; -const restoredPath = `${cacheDirectory}/restored.bin`; - -await diff(oldFilePath, newFilePath, patchPath); -await patch(oldFilePath, restoredPath, patchPath); +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 }; +} ``` -`diff` expects the old and new files to exist and the patch path to be unused. -`patch` expects the old file and patch to exist and the output path to be unused. -Both resolve to `0` on success. +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 ```ts import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; -const oldData = await oldFile.arrayBuffer(); -const newData = await newFile.arrayBuffer(); +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); -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`, and resolve to a -`Uint8Array`. +(including typed arrays and `DataView`), or `Blob`. They resolve to a new +`Uint8Array` and leave the caller's buffers usable. ## Platform API matrix @@ -72,26 +101,32 @@ const restoredData = await patchBytes(oldData, patchData); | Legacy architecture | Yes | Yes | N/A | | New Architecture / TurboModule | Yes | Yes | N/A | -The unsupported API family rejects with `EUNSUPPORTED`, making accidental -cross-platform use explicit. +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) -## Security and resource limits - -Binary diffing is CPU- and memory-intensive. Native work runs off the React -Native module queue and Web work runs in a Worker, but applications should still -apply file-size, time, and trust-boundary limits appropriate to their product. -Validate patch provenance before applying patches received from an untrusted -source. - ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for the local workflow and quality diff --git a/README.zh-CN.md b/README.zh-CN.md index ffb99f8..de8eafc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,20 +1,23 @@ # react-native-bs-diff-patch -在 Android、iOS 和 React Native Web 上生成与应用紧凑的二进制补丁。原生端通过 -JNI / Objective-C++ 调用项目内置的 C 实现;Web 端将同一套实现编译为 -WebAssembly,并在独立 Web Worker 中执行。 +根据文件的两个版本生成紧凑的二进制补丁,再用旧文件和补丁还原新文件。 +Android、iOS 与 React Native Web 共用兼容的 `ENDSLEY/BSDIFF43` 补丁格式。 -[在线文档](https://bs-dff-patch.corerobin.com/docs/) · +[中文文档](https://bs-dff-patch.corerobin.com/docs/zh-CN/) · [在线 Playground](https://bs-dff-patch.corerobin.com/#playground) · -[English](./README.md) +[English](./README.md) · [npm](https://www.npmjs.com/package/react-native-bs-diff-patch) -## 能力概览 +## 为什么使用它? -- Android、iOS、Web 共用 `ENDSLEY/BSDIFF43` 补丁格式。 -- 支持 React Native 旧架构与 TurboModule / 新架构。 -- 原生端使用专用串行工作队列,Web 端使用 Web Worker,避免阻塞 UI。 -- 原生端提供文件路径 API,Web 端提供强类型二进制 API。 -- 提供 TypeScript 类型、WASM 往返测试、真实浏览器测试和 Metro Web 入口测试。 +- **统一补丁格式:** 可以在一个受支持的运行时生成补丁,在另一个运行时应用。 +- **兼容 RN 两种架构:** 同时支持旧桥接架构和 TurboModule / 新架构。 +- **默认不阻塞 UI:** 原生端使用专用串行队列,Web 端使用独立模块 Worker。 +- **Web 无需后端服务:** 浏览器直接运行由同一套 C 核心编译而来的 WebAssembly。 + +| 运行时 | 输入方式 | 生成补丁 | 应用补丁 | +| ------------ | -------------- | ----------- | ------------ | +| Android、iOS | 绝对文件路径 | `diff` | `patch` | +| Web | 内存二进制数据 | `diffBytes` | `patchBytes` | ## 安装 @@ -22,43 +25,67 @@ WebAssembly,并在独立 Web Worker 中执行。 npm install react-native-bs-diff-patch ``` -iOS 项目安装依赖后还需要执行: +添加依赖后,iOS 还需要安装 Pods,并重新构建原生应用: ```sh npx pod-install ``` -## 原生端用法 +React Native autolinking 会完成原生模块注册。新增原生依赖后,只刷新 Metro +不能让模块进入已经安装的应用二进制。 + +## 原生端快速开始 + +原生 API 使用绝对文件路径。请通过项目已经使用的文件系统库选择可写缓存目录。 ```ts import { diff, patch } from 'react-native-bs-diff-patch'; -const patchPath = `${cacheDirectory}/update.patch`; -const restoredPath = `${cacheDirectory}/restored.bin`; - -await diff(oldFilePath, newFilePath, patchPath); -await patch(oldFilePath, restoredPath, patchPath); +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 }; +} ``` -`diff` 要求旧文件和新文件已经存在,补丁路径尚未创建;`patch` 要求旧文件和补丁 -已经存在,输出路径尚未创建。成功时均返回 `0`。 +输出路径不能已经存在,同一次调用中的所有路径必须不同,所需输入文件必须已经 +写入完成。两个函数成功时都返回 `0`。 -## React Native Web 用法 +## React Native Web 快速开始 ```ts import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; -const oldData = await oldFile.arrayBuffer(); -const newData = await newFile.arrayBuffer(); +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); -const patchData = await diffBytes(oldData, newData); -const restoredData = await patchBytes(oldData, patchData); + return { patchData, restoredData }; +} ``` `diffBytes` 和 `patchBytes` 接受 `ArrayBuffer`、任意 `ArrayBufferView` -(包括 TypedArray 和 `DataView`)或 `Blob`,返回 `Uint8Array`。 +(包括 TypedArray 和 `DataView`)或 `Blob`。它们返回新的 `Uint8Array`,且不会 +转移或失效调用方传入的缓冲区。 -## 平台能力 +## 平台能力矩阵 | API | Android | iOS | Web | | --------------------------------------- | ------- | ------ | ------ | @@ -69,22 +96,33 @@ const restoredData = await patchBytes(oldData, patchData); | 旧架构 | 支持 | 支持 | 不适用 | | 新架构 / TurboModule | 支持 | 支持 | 不适用 | -调用当前平台不支持的 API 会以 `EUNSUPPORTED` 拒绝。 +调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他行为。 + +## 生产环境检查清单 + +- 替换业务数据前,验证还原结果与目标文件完全一致。 +- 对远程或其他不可信来源的补丁进行来源认证和完整性校验。 +- 原生端使用唯一输出路径,并在成功或失败后清理临时文件。 +- 按业务设置输入大小和执行时间限制;二进制差分的峰值内存可能达到输入大小的数倍。 +- 使用本库配套生成和应用补丁;通用 `BSDIFF40` 补丁与 + `ENDSLEY/BSDIFF43` 不兼容。 + +错误处理、补丁下载、跨运行时交换和完整性校验示例见 +[生产实践](./docs/zh-CN/recipes.md)。 ## 完整文档 -- [快速开始](./docs/getting-started.md) -- [API 参考](./docs/api-reference.md) -- [平台支持](./docs/platform-support.md) -- [架构与补丁格式](./docs/architecture.md) -- [常见问题与排障](./docs/troubleshooting.md) -- [开发与验证](./docs/development.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) -## 资源与安全边界 +## 参与贡献 -二进制差分会消耗较多 CPU 和内存。虽然本库已经把原生计算移出 React Native 模块 -队列,并在 Web 端使用 Worker,业务仍应按自身场景限制文件大小、执行时间和输入 -来源。应用来自不可信来源的补丁前,应先验证补丁的来源与完整性。 +本地开发流程和质量门禁见 [CONTRIBUTING.md](./CONTRIBUTING.md)。 ## License diff --git a/docs/README.md b/docs/README.md index 4d9cda6..b617052 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,26 @@ # Documentation -This directory is the source of truth for the public documentation rendered at +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 first native/Web patch. +- [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. diff --git a/docs/api-reference.md b/docs/api-reference.md index f6e4d7b..8c1ac65 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,17 @@ # API reference -The package exposes two platform-specific API families under one import path. +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` @@ -18,23 +29,27 @@ Creates a binary patch at `patchFile`. Available on Android and iOS. - `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, - newFile: string, + outputFile: string, patchFile: string ): Promise; ``` -Reconstructs the target file at `newFile`. Available on Android and iOS. +Reconstructs the target file at `outputFile`. Available on Android and iOS. - `oldFile`: existing baseline file path. -- `newFile`: destination path that must not already exist. +- `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` @@ -49,6 +64,10 @@ function diffBytes( Creates a binary patch in a Web Worker. Available on Web. +- Accepts `ArrayBuffer`, any typed-array or `DataView`, and `Blob`. +- Copies inputs, so buffers owned by the caller are not detached. +- Resolves to a new `Uint8Array` containing an `ENDSLEY/BSDIFF43` patch. + ## `patchBytes` ```ts @@ -61,11 +80,29 @@ function patchBytes( 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. | @@ -79,11 +116,16 @@ platform can classify the failure. 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. +operations completing in submission order across separate Web calls, and apply +an application-level concurrency limit for large browser inputs. ## Patch format diff --git a/docs/architecture.md b/docs/architecture.md index 0eaf7ba..65f1026 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,10 @@ 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: @@ -62,6 +66,20 @@ 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 diff --git a/docs/development.md b/docs/development.md index 528b591..5002683 100644 --- a/docs/development.md +++ b/docs/development.md @@ -48,6 +48,8 @@ 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 @@ -74,5 +76,6 @@ For local example commands, see [CONTRIBUTING.md](../CONTRIBUTING.md). 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. Use the repository release command to create the version and tag. -5. Verify the npm package and GitHub release before announcing availability. +4. Confirm English and Chinese public guides describe the same behavior. +5. Use the repository release command to create the version and tag. +6. Verify the npm package and GitHub release before announcing availability. diff --git a/docs/getting-started.md b/docs/getting-started.md index b9b9d2a..d621057 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,7 @@ # Getting started -This guide creates a patch, applies it, and verifies the restored output. +This guide installs the package, selects the correct API family, and completes +a patch round trip. ## Install @@ -8,51 +9,76 @@ This guide creates a patch, applies it, and verifies the restored output. npm install react-native-bs-diff-patch ``` -Install iOS pods after changing native dependencies: +Install iOS pods after adding or updating the native dependency: ```sh npx pod-install ``` -No manual Android package registration is required when React Native -autolinking is enabled. +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. A filesystem library or your own -native code is responsible for creating and reading those files. +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'; -const patchPath = `${cacheDirectory}/release-2.patch`; -const restoredPath = `${cacheDirectory}/release-2.restored`; - -const diffResult = await diff(oldPath, newPath, patchPath); -const patchResult = await patch(oldPath, restoredPath, patchPath); - -if (diffResult !== 0 || patchResult !== 0) { - throw new Error('Binary patch operation failed'); +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`: -- `oldPath` and `newPath` must exist. +- `oldFilePath` and `newFilePath` must exist. - `patchPath` must not exist. -- All three paths must be different. +- All three paths must be non-empty and different. Before calling `patch`: -- `oldPath` and `patchPath` must exist. +- `oldFilePath` and `patchPath` must exist. - `restoredPath` must not exist. -- All three paths must be different. +- All three paths must be non-empty and different. -Remove stale outputs or choose unique names before retrying an operation. +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 data instead of filesystem paths: +React Native Web uses binary values instead of filesystem paths: ```ts import { diffBytes, patchBytes } from 'react-native-bs-diff-patch'; @@ -67,29 +93,39 @@ 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 worker, so the caller's -buffers remain usable. The result is a new `Uint8Array`. +Inputs are copied before being transferred to the module Worker, so the +caller's buffers remain usable. Each result is a new `Uint8Array`. -## Use files in a browser +## Use browser files ```ts -const oldData = await oldFile.arrayBuffer(); -const newData = await newFile.arrayBuffer(); -const patchData = await diffBytes(oldData, newData); - -const download = document.createElement('a'); -download.href = URL.createObjectURL( - new Blob([patchData], { type: 'application/octet-stream' }) -); -download.download = 'update.patch'; -download.click(); +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 -- Read the [API reference](./api-reference.md). +- 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). -- Understand the [execution architecture](./architecture.md). - Try the [live Playground](https://bs-dff-patch.corerobin.com/#playground). diff --git a/docs/platform-support.md b/docs/platform-support.md index 79267f7..057f532 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -11,8 +11,9 @@ | Background execution | Serial executor | Serial dispatch queue | Module Web Worker | | Patch format | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | -The project is continuously exercised with React Native 0.73 and has -compatibility build coverage against newer New Architecture package APIs. +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 @@ -53,8 +54,18 @@ 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/troubleshooting.md b/docs/troubleshooting.md index 6537f1f..94e3079 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,15 @@ # 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 @@ -41,6 +51,10 @@ 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 @@ -53,6 +67,16 @@ 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: 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..0140880 --- /dev/null +++ b/docs/zh-CN/development.md @@ -0,0 +1,79 @@ +# 开发与验证 + +## 前置条件 + +- 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 +在受支持的 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. 通过仓库 release 命令创建版本和 tag。 +6. 对外发布前验证 npm 包和 GitHub release。 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/scripts/build-site.mjs b/scripts/build-site.mjs index dd4decf..5e49481 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -23,6 +23,13 @@ const pages = [ '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', @@ -53,6 +60,51 @@ const pages = [ }, ]; +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('&', '&') @@ -186,9 +238,10 @@ function renderMarkdown(markdown) { if (level > 1) { const text = heading[2]; const id = text + .normalize('NFKC') .toLowerCase() .replace(/`/g, '') - .replace(/[^a-z0-9]+/g, '-') + .replace(/[^\p{Letter}\p{Number}]+/gu, '-') .replace(/^-|-$/g, ''); output.push(`${renderInline(text)}`); } @@ -223,23 +276,70 @@ function renderMarkdown(markdown) { return output.join('\n'); } -function navigation(currentSlug) { +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: 'Documentation home' }, - ...pages.map(({ slug, title }) => ({ slug, title })), + { slug: '', title: ui.homeTitle }, + ...items.map(({ slug, title }) => ({ slug, title })), ] .map(({ slug, title }) => { - const href = slug ? `/docs/${slug}/` : '/docs/'; + 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 }) { - const canonical = slug ? `/docs/${slug}/` : '/docs/'; +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 ` - + @@ -251,32 +351,44 @@ function documentationLayout({ slug, title, description, content }) { )} — react-native-bs-diff-patch" /> + ${escapeHtml(title)} — react-native-bs-diff-patch - +
    -
    `; } -function docsHomeContent() { - return `
    ${pages +function docsHomeContent(items, ui) { + return `
    ${items .map( - ( - { slug, title, description }, - index - ) => ` - ${String(index + 1).padStart(2, '0')} / Guide + ({ slug, title, description }, index) => ` + ${String(index + 1).padStart(2, '0')} / ${ui.guideLabel}

    ${escapeHtml(title)}

    ${escapeHtml(description)}

    ` ) @@ -348,7 +461,9 @@ await writeFile( title: 'Documentation', description: 'Install, integrate, operate, and troubleshoot compatible binary patches across every supported React Native runtime.', - content: docsHomeContent(), + content: docsHomeContent(pages, englishUi), + items: pages, + ui: englishUi, }) ); @@ -361,6 +476,42 @@ for (const page of pages) { 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, }) ); } diff --git a/scripts/test-site-browser.mjs b/scripts/test-site-browser.mjs index b126f0d..56e02fd 100644 --- a/scripts/test-site-browser.mjs +++ b/scripts/test-site-browser.mjs @@ -112,8 +112,21 @@ try { 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, docs, and mobile viewport passed'); + console.log('Site Playground, bilingual docs, and mobile viewport passed'); } finally { await browser.close(); await new Promise((resolve, reject) => diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs index 6007f7c..d5688d5 100644 --- a/scripts/test-site.mjs +++ b/scripts/test-site.mjs @@ -6,6 +6,7 @@ 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', @@ -22,10 +23,19 @@ const requiredFiles = [ '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) { @@ -55,6 +65,21 @@ async function htmlFiles(directory) { 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)) { @@ -96,6 +121,34 @@ for (const htmlPath of await htmlFiles(outputDirectory)) { } } +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' diff --git a/site/assets/site.js b/site/assets/site.js index 8e16516..d0ece0d 100644 --- a/site/assets/site.js +++ b/site/assets/site.js @@ -7,14 +7,16 @@ if (navToggle && navLinks) { navToggle.addEventListener('click', () => { const expanded = navToggle.getAttribute('aria-expanded') === 'true'; navToggle.setAttribute('aria-expanded', String(!expanded)); - navToggle.textContent = expanded ? 'Menu' : 'Close'; + 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 = 'Menu'; + navToggle.textContent = navToggle.dataset.closedLabel || 'Menu'; navLinks.classList.remove('is-open'); } }); diff --git a/site/sitemap.xml b/site/sitemap.xml index 6070884..726d4a4 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -4,8 +4,17 @@ 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/ From ccf7e68723268013a13a618c30c66f57961884f6 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 06:10:19 +0800 Subject: [PATCH 6/9] ci(release): harden validation and trusted publishing --- .github/actions/setup/action.yml | 21 +- .github/workflows/ci.yml | 606 ++++++++++-------- .github/workflows/npm-publish.yml | 108 ++++ .github/workflows/pages.yml | 10 +- CONTRIBUTING.md | 17 +- docs/development.md | 19 +- docs/zh-CN/development.md | 17 +- example/Gemfile | 9 +- example/Gemfile.lock | 122 ++++ .../project.pbxproj | 58 +- example/ios/BsDiffPatchExample/AppDelegate.mm | 7 + .../BsDiffPatchExampleTests.m | 36 +- example/ios/Podfile | 25 +- example/ios/Podfile.lock | 167 +---- package.json | 2 +- 15 files changed, 683 insertions(+), 541 deletions(-) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 example/Gemfile.lock 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 01ddf90..80753e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,386 +1,424 @@ name: CI + on: pull_request: types: [opened, synchronize, reopened] + schedule: + - cron: '17 3 * * 1' workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - install-dep: - runs-on: macos-latest - name: Install dependencies + 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 Dev Changed files - uses: tj-actions/changed-files@v46 - id: verify-dev-changed-files - with: - files: | - !*.md - !*.MD - !*.yml - - - uses: actions/cache@v4 - name: Cache node_modules - id: cache-node-modules - if: steps.verify-dev-changed-files.outputs.any_changed == 'true' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 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@v4 - 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' + 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: | - yarn + 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/**) + 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-build: - runs-on: macos-latest name: Android Build (newArch=${{ matrix.new-arch }}) - needs: install-dep + needs: changes + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 strategy: - fail-fast: false + fail-fast: true matrix: 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/android/** - example/src/** - !example/ios/** - example/e2e/** - - - uses: actions/cache@v4 - 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@v4 - 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-${{ matrix.new-arch }}-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/**/*.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@v4 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - node-version: '18' + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup - name: Set up JDK - uses: actions/setup-java@v4 - 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: 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 -PnewArchEnabled=${{ matrix.new-arch }} + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + cache-provider: basic + cache-read-only: ${{ github.event_name == 'pull_request' }} - - name: Run unit tests - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - run: | - cd example/android - ./gradlew test --stacktrace -PnewArchEnabled=${{ matrix.new-arch }} + - name: Build package + run: yarn prepare - - name: Build APK - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Build Android and run unit tests + working-directory: example/android run: | - npm run prepare - cd example/android - ./gradlew assembleRelease -PnewArchEnabled=${{ matrix.new-arch }} + ./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: + name: Android-Build-Reports-new-arch-${{ matrix.new-arch }} + path: example/android/**/build/reports + if-no-files-found: ignore + retention-days: 7 + - name: Upload APK - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk - path: ${{ github.workspace }}/example/android/app-release-${{ github.sha }}-new-arch-${{ matrix.new-arch }}.apk + 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: - runs-on: macos-latest - needs: android-build 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: false + fail-fast: ${{ github.event_name == 'pull_request' }} + max-parallel: 2 matrix: - api-level: [24, 25, 29, 30, 31] - target: [default] + api-level: ${{ fromJSON(github.event_name == 'pull_request' && '[24,31]' || '[24,25,29,30,31]') }} 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/android/** - example/src/** - !example/ios/** - example/e2e/** - - - uses: actions/cache@v4 - 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@v4 - name: Cache Gradle dependencies - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - id: cache-gradle - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - fail-on-cache-miss: true - key: ${{ runner.os }}-gradle-new-arch-runtime-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('android/**/*.kt', 'example/android/**/*.kt', 'example/android/**/*.gradle') }} + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Set up JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - ruby-version: 2.7 - bundler-cache: true + distribution: zulu + java-version: 17 - - name: Setup node - uses: actions/setup-node@v4 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 with: - node-version: '18' + cache-provider: basic + cache-read-only: true - - name: Set up JDK - uses: actions/setup-java@v4 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' - with: - distribution: 'zulu' - java-version: 17 + - 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: Instrumentation Tests - uses: reactivecircus/android-emulator-runner@v2 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - 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 :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 + script: cd example/android && ./gradlew :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 - - name: Upload Reports - uses: actions/upload-artifact@v4 - if: steps.verify-android-changed-files.outputs.any_changed == 'true' + - name: Upload instrumentation reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: New-Arch-Runtime-Test-Reports-${{ matrix.api-level }}-${{ matrix.target }} - 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 (CocoaPods ${{ matrix.cocoapods }}, newArch=${{ matrix.new-arch }}) + needs: changes + if: needs.changes.outputs.ios == 'true' + runs-on: macos-14 + timeout-minutes: 45 strategy: - fail-fast: false + fail-fast: ${{ github.event_name == 'pull_request' }} + max-parallel: 2 matrix: - cocoapods: ['1.10.1', '1.11.0', '1.14.3'] + cocoapods: ${{ fromJSON(github.event_name == 'pull_request' && '["1.14.3"]' || '["1.10.1","1.11.0","1.14.3"]') }} new-arch: ['0', '1'] 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@v4 - 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 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - name: Cache Pods - id: cache-pods - uses: actions/cache@v4 - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - with: - path: example/ios/Pods - key: ${{ runner.os }}-pods-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}-${{ hashFiles('**/Podfile.lock') }} + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup - name: Set up Ruby - uses: ruby/setup-ruby@v1 - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' + uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1 with: - ruby-version: 2.7 - bundler-cache: true + ruby-version: '2.7' + bundler-cache: ${{ matrix.cocoapods == '1.14.3' }} + working-directory: example - - name: Install Cocoapods - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: gem install cocoapods -v ${{ matrix.cocoapods }} + - name: Install compatibility Ruby gems + if: matrix.cocoapods != '1.14.3' + run: | + gem install ffi -v 1.17.4 --no-document + gem install cocoapods -v ${{ matrix.cocoapods }} --no-document + gem install xcpretty -v 0.3.0 --no-document - - name: Setup node - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - uses: actions/setup-node@v4 + - name: Cache Pods + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: - node-version: '18' + path: | + example/ios/Pods + ~/Library/Caches/CocoaPods + key: ${{ runner.os }}-pods-no-flipper-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}-${{ hashFiles('example/ios/Podfile.lock', 'example/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-no-flipper-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}- - name: Install Pods - if: steps.cache-pods.outputs.cache-hit != 'true' && steps.verify-iOS-changed-files.outputs.any_changed == 'true' + env: + COCOAPODS_VERSION: ${{ matrix.cocoapods }} + NO_FLIPPER: '1' + RCT_NEW_ARCH_ENABLED: ${{ matrix.new-arch }} run: | cd example/ios - pod cache clean --all - RCT_NEW_ARCH_ENABLED=${{ matrix.new-arch }} pod install - - - name: Install xcpretty - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' - run: gem install xcpretty + if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then + bundle exec pod install + else + pod install + fi + + - 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: Build - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' + env: + COCOAPODS_VERSION: ${{ matrix.cocoapods }} run: | + set -o pipefail cd example/ios - xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | xcpretty + if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then + xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | bundle exec xcpretty + else + xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | xcpretty + fi - name: Test - if: steps.verify-iOS-changed-files.outputs.any_changed == 'true' + env: + COCOAPODS_VERSION: ${{ matrix.cocoapods }} + SIMULATOR_UDID: ${{ steps.simulator.outputs.udid }} run: | + set -o pipefail cd example/ios - xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test | xcpretty + if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then + 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 + else + xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" test | xcpretty + fi + + - name: Upload iOS test results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: iOS-Test-Results-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }} + path: ${{ runner.temp }}/ios-tests.xcresult + if-no-files-found: ignore + retention-days: 7 web-test: - runs-on: macos-latest - needs: install-dep 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 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - name: Verify Web Changed files - uses: tj-actions/changed-files@v46 - id: verify-web-changed-files - with: - files: | - cpp/** - src/** - web/** - scripts/build-web-wasm.sh - scripts/test-web.mjs - scripts/test-web-browser.mjs - scripts/test-web-metro.mjs - scripts/web-metro-entry.js - scripts/web-test.html - package.json - yarn.lock - - - uses: actions/cache@v4 - name: Cache node_modules - if: steps.verify-web-changed-files.outputs.any_changed == 'true' - with: - path: | - node_modules - example/node_modules - fail-on-cache-miss: true - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package.json') }}-${{ hashFiles('example/package.json') }} - - - name: Setup node - if: steps.verify-web-changed-files.outputs.any_changed == 'true' - uses: actions/setup-node@v4 - with: - node-version: '18' + - name: Setup Node.js and dependencies + uses: ./.github/actions/setup - name: Build package - if: steps.verify-web-changed-files.outputs.any_changed == 'true' run: yarn prepare - name: Run Web tests - if: steps.verify-web-changed-files.outputs.any_changed == 'true' + env: + CHROME_PATH: /usr/bin/google-chrome run: | yarn test:web yarn test:web:browser yarn test:web:metro - name: Verify npm package contents - if: steps.verify-web-changed-files.outputs.any_changed == 'true' 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, web-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 index 0f36cc8..38cb510 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -29,10 +29,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '18' cache: yarn @@ -55,10 +55,10 @@ jobs: run: yarn site:test:browser - name: Configure GitHub Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: path: site-dist @@ -71,4 +71,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f3687c..08327f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,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: diff --git a/docs/development.md b/docs/development.md index 5002683..711eab8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -77,5 +77,20 @@ For local example commands, see [CONTRIBUTING.md](../CONTRIBUTING.md). 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 the repository release command to create the version and tag. -6. Verify the npm package and GitHub release before announcing availability. +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/zh-CN/development.md b/docs/zh-CN/development.md index 0140880..774a3c6 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -75,5 +75,18 @@ Android CI 构建两种架构模式,并在模拟器矩阵执行新架构设备 2. 检查 `npm pack --dry-run --ignore-scripts`,确认包含 `web/`。 3. 确认公开文档与导出的 TypeScript 声明一致。 4. 确认中英文指南描述同一套公开行为。 -5. 通过仓库 release 命令创建版本和 tag。 -6. 对外发布前验证 npm 包和 GitHub release。 +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/example/Gemfile b/example/Gemfile index 6a7d5c7..abf2695 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -1,7 +1,10 @@ source 'https://rubygems.org' -# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby ">= 2.6.10" +# Keep the CI Ruby toolchain reproducible, including the last ffi release that +# supports Ruby 2.7. +ruby '>= 2.7.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/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/package.json b/package.json index 5c0573c..29a0edd 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "tagName": "v${version}" }, "npm": { - "publish": true + "publish": false }, "github": { "release": true From 66341d7fcdcaa9ca84fd5dec5a51141df02934fc Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 06:53:47 +0800 Subject: [PATCH 7/9] ci(ios): simplify CocoaPods validation --- .github/workflows/ci.yml | 46 +++++++-------------------------------- docs/development.md | 4 ++-- docs/zh-CN/development.md | 2 +- example/Gemfile | 5 ++--- 4 files changed, 13 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80753e8..9b8500a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,7 @@ jobs: retention-days: 7 ios-build-test: - name: iOS (CocoaPods ${{ matrix.cocoapods }}, newArch=${{ matrix.new-arch }}) + name: iOS (newArch=${{ matrix.new-arch }}) needs: changes if: needs.changes.outputs.ios == 'true' runs-on: macos-14 @@ -248,7 +248,6 @@ jobs: fail-fast: ${{ github.event_name == 'pull_request' }} max-parallel: 2 matrix: - cocoapods: ${{ fromJSON(github.event_name == 'pull_request' && '["1.14.3"]' || '["1.10.1","1.11.0","1.14.3"]') }} new-arch: ['0', '1'] steps: - name: Checkout the code @@ -260,39 +259,27 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1 with: - ruby-version: '2.7' - bundler-cache: ${{ matrix.cocoapods == '1.14.3' }} + ruby-version: '3.0' + bundler-cache: true working-directory: example - - name: Install compatibility Ruby gems - if: matrix.cocoapods != '1.14.3' - run: | - gem install ffi -v 1.17.4 --no-document - gem install cocoapods -v ${{ matrix.cocoapods }} --no-document - gem install xcpretty -v 0.3.0 --no-document - - name: Cache Pods uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | example/ios/Pods ~/Library/Caches/CocoaPods - key: ${{ runner.os }}-pods-no-flipper-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}-${{ hashFiles('example/ios/Podfile.lock', 'example/Gemfile.lock') }} + 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-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }}- + ${{ runner.os }}-pods-no-flipper-new-arch-${{ matrix.new-arch }}- - name: Install Pods env: - COCOAPODS_VERSION: ${{ matrix.cocoapods }} NO_FLIPPER: '1' RCT_NEW_ARCH_ENABLED: ${{ matrix.new-arch }} run: | cd example/ios - if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then - bundle exec pod install - else - pod install - fi + bundle exec pod install - name: Select an available iPhone simulator id: simulator @@ -304,36 +291,19 @@ jobs: fi echo "udid=$simulator_udid" >> "$GITHUB_OUTPUT" - - name: Build - env: - COCOAPODS_VERSION: ${{ matrix.cocoapods }} - run: | - set -o pipefail - cd example/ios - if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then - xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | bundle exec xcpretty - else - xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' | xcpretty - fi - - name: Test env: - COCOAPODS_VERSION: ${{ matrix.cocoapods }} SIMULATOR_UDID: ${{ steps.simulator.outputs.udid }} run: | set -o pipefail cd example/ios - if [[ "$COCOAPODS_VERSION" == "1.14.3" ]]; then - 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 - else - xcodebuild -workspace BsDiffPatchExample.xcworkspace -scheme BsDiffPatchExample -configuration Release -sdk iphonesimulator -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" test | xcpretty - fi + 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-${{ matrix.cocoapods }}-new-arch-${{ matrix.new-arch }} + name: iOS-Test-Results-new-arch-${{ matrix.new-arch }} path: ${{ runner.temp }}/ios-tests.xcresult if-no-files-found: ignore retention-days: 7 diff --git a/docs/development.md b/docs/development.md index 711eab8..72507b0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,8 +66,8 @@ 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 builds and tests legacy and New -Architecture configurations across the supported CocoaPods matrix. +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). diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md index 774a3c6..3032db5 100644 --- a/docs/zh-CN/development.md +++ b/docs/zh-CN/development.md @@ -64,7 +64,7 @@ yarn test:web:browser ## 原生验证 Android CI 构建两种架构模式,并在模拟器矩阵执行新架构设备级往返测试。iOS CI -在受支持的 CocoaPods 矩阵中构建并测试旧架构和新架构配置。 +使用示例 Gemfile 锁定的 CocoaPods 版本构建并测试旧架构和新架构配置。 本地示例命令见仓库 [CONTRIBUTING.md](https://github.com/JimmyDaddy/react-native-bs-diff-patch/blob/main/CONTRIBUTING.md)。 diff --git a/example/Gemfile b/example/Gemfile index abf2695..501b276 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -1,8 +1,7 @@ source 'https://rubygems.org' -# Keep the CI Ruby toolchain reproducible, including the last ffi release that -# supports Ruby 2.7. -ruby '>= 2.7.0' +# The ARM64 ffi package requires Ruby 3 or newer. +ruby '>= 3.0.0' gem 'activesupport', '>= 6.1.7.3', '< 7.1.0' gem 'cocoapods', '1.14.3' From 8048a57c5762c8262d724b4ec9fdc0634048268b Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 07:19:42 +0800 Subject: [PATCH 8/9] ci(android): stabilize API 24 emulator coverage --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b8500a..0954eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,7 @@ jobs: fail-fast: ${{ github.event_name == 'pull_request' }} max-parallel: 2 matrix: - api-level: ${{ fromJSON(github.event_name == 'pull_request' && '[24,31]' || '[24,25,29,30,31]') }} + api-level: ${{ fromJSON(github.event_name == 'pull_request' && '[31]' || '[24,25,29,30,31]') }} steps: - name: Checkout the code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -225,7 +225,35 @@ jobs: target: default arch: x86_64 profile: Nexus 6 - script: cd example/android && ./gradlew :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 + script: | + cd example/android + run_runtime_test() { + ./gradlew :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 + } + + if run_runtime_test; then + exit 0 + fi + + if [ "${{ matrix.api-level }}" -ne 24 ]; then + exit 1 + fi + + echo '::warning::API 24 emulator failed; rebooting it before one retry.' + adb reboot + 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 + + run_runtime_test - name: Upload instrumentation reports if: always() From 8b575a6e18821f0d90b77124259e2c2282679a24 Mon Sep 17 00:00:00 2001 From: JimmyDaddy Date: Sun, 19 Jul 2026 07:30:05 +0800 Subject: [PATCH 9/9] fix(ci): execute Android retry in one shell --- .github/workflows/ci.yml | 32 ++--------------------- scripts/run-android-runtime-test.sh | 40 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 scripts/run-android-runtime-test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0954eb9..cda5f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: quality=true web=true ;; - android/**|example/android/**) + android/**|example/android/**|scripts/run-android-runtime-test.sh) android=true quality=true ;; @@ -225,35 +225,7 @@ jobs: target: default arch: x86_64 profile: Nexus 6 - script: | - cd example/android - run_runtime_test() { - ./gradlew :app:connectedReleaseAndroidTest --stacktrace -PnewArchEnabled=true -PreactNativeArchitectures=x86_64 - } - - if run_runtime_test; then - exit 0 - fi - - if [ "${{ matrix.api-level }}" -ne 24 ]; then - exit 1 - fi - - echo '::warning::API 24 emulator failed; rebooting it before one retry.' - adb reboot - 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 - - run_runtime_test + script: sh scripts/run-android-runtime-test.sh ${{ matrix.api-level }} - name: Upload instrumentation reports if: always() 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