diff --git a/.github/workflows/native-benchmark.yml b/.github/workflows/native-benchmark.yml
index aee8556..f9fd1af 100644
--- a/.github/workflows/native-benchmark.yml
+++ b/.github/workflows/native-benchmark.yml
@@ -4,6 +4,12 @@ on:
schedule:
- cron: '23 5 * * 3'
workflow_dispatch:
+ inputs:
+ sizes_mib:
+ description: Comma-separated input sizes in MiB
+ required: true
+ default: '1,10,50'
+ type: string
permissions:
contents: read
@@ -33,6 +39,7 @@ jobs:
- name: Run deterministic benchmark
env:
BENCHMARK_OUTPUT: native-core-${{ runner.os }}.json
+ BENCHMARK_SIZES_MIB: ${{ inputs.sizes_mib || '1,10,50' }}
run: node scripts/benchmark-native.mjs
- name: Upload benchmark report
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index e6f7a83..4d98ea7 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -58,7 +58,7 @@ jobs:
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload GitHub Pages artifact
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: site-dist
@@ -71,4 +71,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
diff --git a/README.md b/README.md
index 78f84a9..d7e500c 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@
Documentation ·
Live Playground ·
+ Binary Patch Toolkit ·
中文说明 ·
npm
@@ -50,15 +51,18 @@ replaces live data.
cap input/output sizes, and avoid exposing partial output files.
- **No patch service required:** Web diffing and patching happen locally in the
browser.
+- **Inspect and prove compatibility:** read patch metadata and verify restored
+ bytes through the same API shape on native and Web.
## Platform overview
-| | Android / iOS | React Native Web |
-| -------------- | ------------------------------ | -------------------------------------------------- |
-| Input | Absolute file paths | `ArrayBuffer`, typed arrays, `DataView`, or `Blob` |
-| Basic API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
-| Controlled API | `startDiff()` / `startPatch()` | `AbortSignal` and binary limits |
-| Engine | Native C via JNI / ObjC++ | Same C core via WASM Worker |
+| | Android / iOS | React Native Web |
+| -------------- | -------------------------------------------- | -------------------------------------------------- |
+| Input | Absolute file paths | `ArrayBuffer`, typed arrays, `DataView`, or `Blob` |
+| Basic API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
+| Controlled API | `startDiff()` / `startPatch()` | `AbortSignal` and binary limits |
+| Verification | Paths via `inspectPatch()` / `verifyPatch()` | Binary values via the same APIs |
+| Engine | Native C via JNI / ObjC++ | Same C core via WASM Worker |
## Install
@@ -138,17 +142,44 @@ Web calls return a new `Uint8Array` and leave caller-owned buffers usable.
Aborted operations reject with `EABORTED`; configured binary limits reject with
`ERESOURCE`.
+## Inspect and verify a patch
+
+Use `inspectPatch()` for a cheap structural check, then `verifyPatch()` to apply
+into a temporary result and compare it with the expected target byte-for-byte:
+
+```ts
+import { inspectPatch, verifyPatch } from 'react-native-bs-diff-patch';
+
+// Android / iOS use paths. Web uses File, Blob, ArrayBuffer, or typed arrays.
+const metadata = await inspectPatch(patchPath);
+const result = await verifyPatch(oldPath, patchPath, expectedPath, {
+ maxInputBytes: 64 * 1024 * 1024,
+ maxOutputBytes: 128 * 1024 * 1024,
+});
+
+if (!metadata.valid || !result.verified) {
+ throw new Error('Patch compatibility check failed');
+}
+```
+
+The native verification output is temporary and always cleaned up. The Web
+form accepts `oldFile`, `patchFile`, and `expectedFile` in the same argument
+order. Structural validity is diagnostic; authenticate trusted hashes in your
+update manifest before replacing live data.
+
## API matrix
-| API | Android | iOS | Web |
-| ------------------------------------------ | ------- | --- | --- |
-| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No |
-| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No |
-| `startDiff(...)` / `startPatch(...)` | Yes | Yes | No |
-| `diffBytes(oldData, newData, options?)` | No | No | Yes |
-| `patchBytes(oldData, patchData, options?)` | No | No | Yes |
-| Legacy architecture, while supplied by RN | Yes | Yes | N/A |
-| New Architecture / TurboModule | Yes | Yes | N/A |
+| API | Android | iOS | Web |
+| --------------------------------------------- | ------- | --- | --- |
+| `diff(oldPath, newPath, patchPath)` | Yes | Yes | No |
+| `patch(oldPath, outputPath, patchPath)` | Yes | Yes | No |
+| `startDiff(...)` / `startPatch(...)` | Yes | Yes | No |
+| `diffBytes(oldData, newData, options?)` | No | No | Yes |
+| `patchBytes(oldData, patchData, options?)` | No | No | Yes |
+| `inspectPatch(path or binary, options?)` | Yes | Yes | Yes |
+| `verifyPatch(old, patch, expected, options?)` | Yes | Yes | Yes |
+| Legacy architecture, while supplied by RN | Yes | Yes | N/A |
+| New Architecture / TurboModule | Yes | Yes | N/A |
Unavailable platform APIs reject with `EUNSUPPORTED`; the package never
silently switches to a different input model.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index adcecec..3993231 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -21,6 +21,7 @@
中文文档 ·
在线 Playground ·
+ 二进制补丁工具箱 ·
English ·
npm
@@ -47,15 +48,17 @@
- **可控制高成本任务:** 原生 job 支持进度、协作式取消、输入/输出限制,并避免
暴露未完成的输出文件。
- **Web 无需补丁服务:** 差分和还原完全在浏览器本地执行。
+- **检查并证明兼容性:** 原生与 Web 使用相同 API 读取补丁元数据,并验证还原字节。
## 平台概览
-| | Android / iOS | React Native Web |
-| -------- | ------------------------------ | ----------------------------------------------- |
-| 输入 | 绝对文件路径 | `ArrayBuffer`、TypedArray、`DataView` 或 `Blob` |
-| 基础 API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
-| 可控 API | `startDiff()` / `startPatch()` | `AbortSignal` 与二进制大小限制 |
-| 执行核心 | JNI / ObjC++ 调用原生 C | WASM Worker 运行同一 C 核心 |
+| | Android / iOS | React Native Web |
+| -------- | ----------------------------------------- | ----------------------------------------------- |
+| 输入 | 绝对文件路径 | `ArrayBuffer`、TypedArray、`DataView` 或 `Blob` |
+| 基础 API | `diff()` / `patch()` | `diffBytes()` / `patchBytes()` |
+| 可控 API | `startDiff()` / `startPatch()` | `AbortSignal` 与二进制大小限制 |
+| 验证能力 | 路径版 `inspectPatch()` / `verifyPatch()` | 相同 API 的二进制输入 |
+| 执行核心 | JNI / ObjC++ 调用原生 C | WASM Worker 运行同一 C 核心 |
## 安装
@@ -134,17 +137,43 @@ const restoredBytes = await patchBytes(oldBytes, patchBytesValue, {
Web API 返回新的 `Uint8Array`,不会转移或失效调用方的缓冲区。主动取消以
`EABORTED` 拒绝;命中二进制大小限制时以 `ERESOURCE` 拒绝。
+## 检查并验证补丁
+
+先用 `inspectPatch()` 完成低成本结构检查,再用 `verifyPatch()` 将补丁应用到临时
+结果,并与预期目标逐字节比较:
+
+```ts
+import { inspectPatch, verifyPatch } from 'react-native-bs-diff-patch';
+
+// Android / iOS 使用路径;Web 使用 File、Blob、ArrayBuffer 或 TypedArray。
+const metadata = await inspectPatch(patchPath);
+const result = await verifyPatch(oldPath, patchPath, expectedPath, {
+ maxInputBytes: 64 * 1024 * 1024,
+ maxOutputBytes: 128 * 1024 * 1024,
+});
+
+if (!metadata.valid || !result.verified) {
+ throw new Error('补丁兼容性验证失败');
+}
+```
+
+原生验证产生的临时输出始终会被清理。Web 版本按相同顺序传入 `oldFile`、
+`patchFile` 与 `expectedFile`。结构有效只用于诊断;替换业务数据前仍应认证更新清单
+中的可信哈希。
+
## API 矩阵
-| API | Android | iOS | Web |
-| ------------------------------------------ | ------- | ------ | ------ |
-| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 |
-| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 |
-| `startDiff(...)` / `startPatch(...)` | 支持 | 支持 | 不支持 |
-| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 |
-| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 |
-| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 |
-| 新架构 / TurboModule | 支持 | 支持 | 不适用 |
+| API | Android | iOS | Web |
+| --------------------------------------------- | ------- | ------ | ------ |
+| `diff(oldPath, newPath, patchPath)` | 支持 | 支持 | 不支持 |
+| `patch(oldPath, outputPath, patchPath)` | 支持 | 支持 | 不支持 |
+| `startDiff(...)` / `startPatch(...)` | 支持 | 支持 | 不支持 |
+| `diffBytes(oldData, newData, options?)` | 不支持 | 不支持 | 支持 |
+| `patchBytes(oldData, patchData, options?)` | 不支持 | 不支持 | 支持 |
+| `inspectPatch(path 或 binary, options?)` | 支持 | 支持 | 支持 |
+| `verifyPatch(old, patch, expected, options?)` | 支持 | 支持 | 支持 |
+| 旧架构(限 RN 仍提供时) | 支持 | 支持 | 不适用 |
+| 新架构 / TurboModule | 支持 | 支持 | 不适用 |
调用当前平台不可用的 API 会以 `EUNSUPPORTED` 拒绝,不会静默切换成其他输入
模型。
diff --git a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
index 44076b9..67a9808 100644
--- a/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
+++ b/android/newarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
@@ -17,6 +17,25 @@ class BsDiffPatchModule(reactContext: ReactApplicationContext) :
override fun diff(oldFile: String, newFile: String, patchFile: String, promise: Promise) =
support.diff(oldFile, newFile, patchFile, promise)
+ override fun inspectPatch(patchFile: String, maxInputBytes: Double, promise: Promise) =
+ support.inspectPatch(patchFile, maxInputBytes, promise)
+
+ override fun verifyPatch(
+ oldFile: String,
+ patchFile: String,
+ expectedFile: String,
+ maxInputBytes: Double,
+ maxOutputBytes: Double,
+ promise: Promise
+ ) = support.verifyPatch(
+ oldFile,
+ patchFile,
+ expectedFile,
+ maxInputBytes,
+ maxOutputBytes,
+ promise
+ )
+
override fun startPatch(
jobId: String,
oldFile: String,
diff --git a/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
index 38813c4..c25fb9b 100644
--- a/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
+++ b/android/oldarch/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModule.kt
@@ -21,6 +21,27 @@ class BsDiffPatchModule(reactContext: ReactApplicationContext) :
fun diff(oldFile: String, newFile: String, patchFile: String, promise: Promise) =
support.diff(oldFile, newFile, patchFile, promise)
+ @ReactMethod
+ fun inspectPatch(patchFile: String, maxInputBytes: Double, promise: Promise) =
+ support.inspectPatch(patchFile, maxInputBytes, promise)
+
+ @ReactMethod
+ fun verifyPatch(
+ oldFile: String,
+ patchFile: String,
+ expectedFile: String,
+ maxInputBytes: Double,
+ maxOutputBytes: Double,
+ promise: Promise
+ ) = support.verifyPatch(
+ oldFile,
+ patchFile,
+ expectedFile,
+ maxInputBytes,
+ maxOutputBytes,
+ promise
+ )
+
@ReactMethod
fun startPatch(
jobId: String,
diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModuleSupport.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModuleSupport.kt
index 0659107..7ff19d0 100644
--- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModuleSupport.kt
+++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchModuleSupport.kt
@@ -4,6 +4,7 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.core.DeviceEventManagerModule
+import java.io.File
import java.util.concurrent.atomic.AtomicInteger
internal class BsDiffPatchModuleSupport(
@@ -28,6 +29,47 @@ internal class BsDiffPatchModuleSupport(
}
}
+ fun inspectPatch(patchFile: String, maxInputBytes: Double, promise: Promise) {
+ taskRunner.execute(promise) {
+ BsDiffPatchNative.inspectPatch(patchFile, maxInputBytes)
+ }
+ }
+
+ fun verifyPatch(
+ oldFile: String,
+ patchFile: String,
+ expectedFile: String,
+ maxInputBytes: Double,
+ maxOutputBytes: Double,
+ promise: Promise
+ ) {
+ taskRunner.execute(promise) {
+ val temporaryOutput = File.createTempFile(
+ "bsdiffpatch-verify-",
+ ".tmp",
+ reactContext.cacheDir
+ )
+ if (!temporaryOutput.delete()) {
+ throw BsDiffPatchException(
+ "EUNSPECIFIED",
+ "could not prepare temporary verification output"
+ )
+ }
+ try {
+ BsDiffPatchNative.verifyPatch(
+ oldFile,
+ patchFile,
+ expectedFile,
+ temporaryOutput.absolutePath,
+ maxInputBytes,
+ maxOutputBytes
+ )
+ } finally {
+ temporaryOutput.delete()
+ }
+ }
+ }
+
fun startPatch(
jobId: String,
oldFile: String,
diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt
index 8d0a730..c77172a 100644
--- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt
+++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchNative.kt
@@ -1,7 +1,9 @@
package com.jimmydaddy.bsdiffpatch
import java.io.File
+import java.io.FileInputStream
import java.io.RandomAccessFile
+import org.json.JSONObject
import kotlin.math.floor
internal object BsDiffPatchNative {
@@ -34,6 +36,76 @@ internal object BsDiffPatchNative {
)
}
+ fun inspectPatch(patchFile: String, maxInputBytes: Double): String {
+ val inputLimit = validateLimit(maxInputBytes, "maxInputBytes")
+ val patch = File(normalizePath(patchFile))
+ validateNonEmpty(patchFile, "patchFile")
+ requireInput(patch, "patchFile", patchFile)
+ enforceInputLimit(inputLimit, patch)
+ return inspectPatchFile(patch).toJson().toString()
+ }
+
+ fun verifyPatch(
+ oldFile: String,
+ patchFile: String,
+ expectedFile: String,
+ outputFile: String,
+ maxInputBytes: Double,
+ maxOutputBytes: Double
+ ): String {
+ validateNonEmpty(oldFile, "oldFile")
+ validateNonEmpty(patchFile, "patchFile")
+ validateNonEmpty(expectedFile, "expectedFile")
+ validateNonEmpty(outputFile, "outputFile")
+ val normalized = listOf(oldFile, patchFile, expectedFile, outputFile).map(::normalizePath)
+ if (normalized.toSet().size != normalized.size) {
+ throw BsDiffPatchException(
+ "EINVAL",
+ "oldFile, patchFile, expectedFile, and internal output can not be the same"
+ )
+ }
+
+ val old = File(normalized[0])
+ val patch = File(normalized[1])
+ val expected = File(normalized[2])
+ val output = File(normalized[3])
+ requireInput(old, "oldFile", oldFile)
+ requireInput(patch, "patchFile", patchFile)
+ requireInput(expected, "expectedFile", expectedFile)
+ requireOutput(output, "outputFile", outputFile)
+
+ val inputLimit = validateLimit(maxInputBytes, "maxInputBytes")
+ val outputLimit = validateLimit(maxOutputBytes, "maxOutputBytes")
+ enforceInputLimit(inputLimit, old, patch, expected)
+ enforcePatchOutputLimit(outputLimit, patch)
+ val metadata = inspectPatchFile(patch)
+ if (!metadata.valid) {
+ throw BsDiffPatchException(
+ "EPATCH",
+ "patch structure is invalid: ${metadata.issue ?: "UNKNOWN"}"
+ )
+ }
+
+ requireSuccess(
+ "EPATCH",
+ "patch verification",
+ bsPatchFile(old.absolutePath, output.absolutePath, patch.absolutePath)
+ )
+ if (outputLimit > 0 && output.length() > outputLimit) {
+ throw BsDiffPatchException(
+ "EOUTPUT_TOO_LARGE",
+ "output is ${output.length()} bytes and exceeds the configured $outputLimit byte limit"
+ )
+ }
+ val verified = filesEqual(output, expected)
+ return JSONObject()
+ .put("verified", verified)
+ .put("restoredBytes", output.length())
+ .put("expectedBytes", expected.length())
+ .put("patch", metadata.toJson())
+ .toString()
+ }
+
fun patchJob(
jobId: String,
oldFile: String,
@@ -189,6 +261,83 @@ internal object BsDiffPatchNative {
}
}
+ private fun inspectPatchFile(patchFile: File): PatchMetadataValue {
+ val patchBytes = patchFile.length()
+ val headerBytes = minOf(patchBytes, 24L).toInt()
+ val header = ByteArray(headerBytes)
+ FileInputStream(patchFile).use { input ->
+ var offset = 0
+ while (offset < header.size) {
+ val count = input.read(header, offset, header.size - offset)
+ if (count < 0) break
+ offset += count
+ }
+ }
+ val legacyMagic = header.copyOfRange(0, minOf(8, header.size)).toString(Charsets.US_ASCII)
+ val currentMagic = header.copyOfRange(0, minOf(16, header.size)).toString(Charsets.US_ASCII)
+ if (header.size < 24) {
+ return PatchMetadataValue(
+ format = if (legacyMagic == "BSDIFF40") "BSDIFF40" else "UNKNOWN",
+ patchBytes = patchBytes,
+ headerBytes = header.size,
+ declaredTargetBytes = null,
+ valid = false,
+ issue = if (legacyMagic == "BSDIFF40") "LEGACY_FORMAT" else "TRUNCATED_HEADER"
+ )
+ }
+ if (currentMagic != "ENDSLEY/BSDIFF43") {
+ return PatchMetadataValue(
+ format = if (legacyMagic == "BSDIFF40") "BSDIFF40" else "UNKNOWN",
+ patchBytes = patchBytes,
+ headerBytes = 24,
+ declaredTargetBytes = null,
+ valid = false,
+ issue = if (legacyMagic == "BSDIFF40") "LEGACY_FORMAT" else "INVALID_MAGIC"
+ )
+ }
+ if (header[23].toInt() and 0x80 != 0) {
+ return PatchMetadataValue(
+ format = "ENDSLEY/BSDIFF43",
+ patchBytes = patchBytes,
+ headerBytes = 24,
+ declaredTargetBytes = null,
+ valid = false,
+ issue = "INVALID_TARGET_SIZE"
+ )
+ }
+ var targetBytes = 0L
+ for (index in 23 downTo 16) {
+ targetBytes = targetBytes * 256 + (header[index].toInt() and 0xff)
+ }
+ return PatchMetadataValue(
+ format = "ENDSLEY/BSDIFF43",
+ patchBytes = patchBytes,
+ headerBytes = 24,
+ declaredTargetBytes = targetBytes.toString(),
+ valid = true,
+ issue = null
+ )
+ }
+
+ private fun filesEqual(first: File, second: File): Boolean {
+ if (first.length() != second.length()) return false
+ FileInputStream(first).buffered().use { firstInput ->
+ FileInputStream(second).buffered().use { secondInput ->
+ val firstBuffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ val secondBuffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val firstCount = firstInput.read(firstBuffer)
+ val secondCount = secondInput.read(secondBuffer)
+ if (firstCount != secondCount) return false
+ if (firstCount < 0) return true
+ for (index in 0 until firstCount) {
+ if (firstBuffer[index] != secondBuffer[index]) return false
+ }
+ }
+ }
+ }
+ }
+
private fun requireInput(file: File, fieldName: String, originalPath: String) {
if (!file.exists()) {
throw BsDiffPatchException("ENOENT", "$fieldName: $originalPath does not exist")
@@ -273,6 +422,26 @@ internal object BsDiffPatchNative {
private external fun bsCancelOperation(jobId: String): Boolean
private data class Paths(val old: File, val input: File, val output: File)
+
+ private data class PatchMetadataValue(
+ val format: String,
+ val patchBytes: Long,
+ val headerBytes: Int,
+ val declaredTargetBytes: String?,
+ val valid: Boolean,
+ val issue: String?
+ ) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("format", format)
+ .put("patchBytes", patchBytes)
+ .put("headerBytes", headerBytes)
+ .put("payloadBytes", (patchBytes - 24L).coerceAtLeast(0L))
+ .put("declaredTargetBytes", declaredTargetBytes ?: JSONObject.NULL)
+ .put("valid", valid)
+ .apply {
+ if (issue != null) put("issue", issue)
+ }
+ }
}
internal class BsDiffPatchException(
diff --git a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt
index 9b7a6f6..9ae44e8 100644
--- a/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt
+++ b/android/src/main/java/com/jimmydaddy/bsdiffpatch/BsDiffPatchTaskRunner.kt
@@ -14,7 +14,7 @@ internal class BsDiffPatchTaskRunner {
Thread(runnable, "BsDiffPatchWorker")
}
- fun execute(promise: Promise, block: () -> Int) {
+ fun execute(promise: Promise, block: () -> Any?) {
submit(promise) { block() }
}
@@ -52,7 +52,7 @@ internal class BsDiffPatchTaskRunner {
return activeJobIds
}
- private fun submit(promise: Promise, block: () -> Int): Boolean {
+ private fun submit(promise: Promise, block: () -> Any?): Boolean {
try {
executor.execute {
try {
diff --git a/benchmarks/README.md b/benchmarks/README.md
index aa2cd48..bc6829f 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -1,15 +1,29 @@
# Benchmarks
-`web-wasm.json` is a reproducible reference measurement for the checked-in
-WebAssembly implementation. It is not a performance guarantee: file contents,
-hardware, browser scheduling, and available memory can change results.
+`web-wasm.json` and `native-core.json` are the inexpensive reference trend
+lines. `web-wasm-large.json` and `native-core-large.json` record the explicit
+16/64/128 MiB feasibility profile. They are not performance guarantees: file
+contents, hardware, browser scheduling, and available memory can change
+results.
Regenerate the 1, 10, and 50 MiB workload with:
```sh
BENCHMARK_OUTPUT=benchmarks/web-wasm.json yarn benchmark:web
+BENCHMARK_OUTPUT=benchmarks/native-core.json yarn benchmark:native
+BENCHMARK_OUTPUT=benchmarks/web-wasm-large.json yarn benchmark:large:web
+BENCHMARK_OUTPUT=benchmarks/native-core-large.json yarn benchmark:large:native
```
The workload changes one byte per 4 KiB and verifies every restored byte. The
-benchmark measures the WebAssembly core after a small initialization warm-up;
-the public browser API additionally transfers data through a module Worker.
+Web benchmark measures the WebAssembly core after a small initialization
+warm-up; the public browser API additionally transfers data through a module
+Worker. Every size runs in a fresh process so peak resident memory is
+comparable.
+
+On the recorded Apple M3 Pro baseline, native completed all three large sizes.
+Web completed 16 and 64 MiB, but its 128 MiB diff returned `EWEBASSEMBLY` after
+reaching the current WebAssembly memory boundary. The failed sample is retained
+intentionally: it is a measured limitation, not a flaky result. See the
+[large-file roadmap](../docs/large-files-v04.md) before interpreting or changing
+these limits.
diff --git a/benchmarks/native-core-large.json b/benchmarks/native-core-large.json
new file mode 100644
index 0000000..ec638e9
--- /dev/null
+++ b/benchmarks/native-core-large.json
@@ -0,0 +1,42 @@
+{
+ "generatedAt": "2026-07-20T06:10:29.044Z",
+ "runtime": {
+ "cpu": "Apple M3 Pro",
+ "compiler": "Apple clang version 17.0.0 (clang-1700.6.3.2)",
+ "platform": "darwin-arm64"
+ },
+ "workload": {
+ "description": "Deterministic files with one changed byte per 4 KiB",
+ "memoryMetric": "Peak resident set size for one diff and patch process"
+ },
+ "summary": {
+ "passed": 3,
+ "failed": 0
+ },
+ "results": [
+ {
+ "status": "passed",
+ "sizeMiB": 16,
+ "diffMs": 10012.4,
+ "patchMs": 70.7,
+ "patchBytes": 113,
+ "peakRssKiB": 316368
+ },
+ {
+ "status": "passed",
+ "sizeMiB": 64,
+ "diffMs": 41943.7,
+ "patchMs": 249,
+ "patchBytes": 189,
+ "peakRssKiB": 1256080
+ },
+ {
+ "status": "passed",
+ "sizeMiB": 128,
+ "diffMs": 101698.9,
+ "patchMs": 586.4,
+ "patchBytes": 323,
+ "peakRssKiB": 2486416
+ }
+ ]
+}
diff --git a/benchmarks/web-wasm-large.json b/benchmarks/web-wasm-large.json
new file mode 100644
index 0000000..0a49f0f
--- /dev/null
+++ b/benchmarks/web-wasm-large.json
@@ -0,0 +1,50 @@
+{
+ "generatedAt": "2026-07-20T06:07:44.301Z",
+ "runtime": {
+ "cpu": "Apple M3 Pro",
+ "node": "v22.22.0",
+ "platform": "darwin-arm64"
+ },
+ "workload": {
+ "description": "Deterministic buffers with one changed byte per 4 KiB",
+ "memoryMetric": "Peak resident set size for one initialized WebAssembly diff and patch process",
+ "processIsolation": "Each input size runs in a fresh Node.js process"
+ },
+ "summary": {
+ "passed": 2,
+ "failed": 1
+ },
+ "results": [
+ {
+ "sizeMiB": 16,
+ "status": "passed",
+ "initializationMs": 15.6,
+ "diffMs": 8769.2,
+ "patchMs": 125.4,
+ "patchBytes": 113,
+ "peakRssMiB": 639.1,
+ "residentAfterMiB": 639.1,
+ "externalAfterMiB": 442.2,
+ "arrayBuffersAfterMiB": 129.1
+ },
+ {
+ "sizeMiB": 64,
+ "status": "passed",
+ "initializationMs": 13.2,
+ "diffMs": 49136.5,
+ "patchMs": 784.7,
+ "patchBytes": 189,
+ "peakRssMiB": 2094.3,
+ "residentAfterMiB": 2094.3,
+ "externalAfterMiB": 1614.7,
+ "arrayBuffersAfterMiB": 389.6
+ },
+ {
+ "sizeMiB": 128,
+ "status": "failed",
+ "errorCode": "EWEBASSEMBLY",
+ "errorMessage": "diff failed: native function returned -1",
+ "peakRssMiB": 851.4
+ }
+ ]
+}
diff --git a/docs/README.md b/docs/README.md
index eff93af..9a535e2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -25,6 +25,8 @@ The [Chinese documentation](./zh-CN/README.md) mirrors the same public guides.
- [Architecture](./architecture.md) — execution paths and patch compatibility.
- [Controllable native operations](./native-operations-v03.md) — resource limits,
cancellation, progress, and atomic output contract.
+- [Large-file roadmap](./large-files-v04.md) — memory baselines, honest progress,
+ and streaming feasibility for the next architecture iteration.
- [Troubleshooting](./troubleshooting.md) — common integration failures.
- [Development](./development.md) — local builds, tests, WebAssembly, and release checks.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index c9853f2..19b66d4 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -11,8 +11,12 @@ import {
startPatch,
diffBytes,
patchBytes,
+ inspectPatch,
+ verifyPatch,
type BinaryInput,
type BinaryOperationOptions,
+ type PatchMetadata,
+ type PatchVerificationResult,
} from 'react-native-bs-diff-patch';
```
@@ -147,6 +151,85 @@ bytes. Available on Web.
- Rejects before allocating the declared output when the patch header exceeds
`maxOutputBytes`.
+## `inspectPatch`
+
+```ts
+interface PatchInspectionOptions {
+ maxInputBytes?: number;
+}
+
+interface PatchMetadata {
+ format: 'ENDSLEY/BSDIFF43' | 'BSDIFF40' | 'UNKNOWN';
+ patchBytes: number;
+ headerBytes: number;
+ payloadBytes: number;
+ declaredTargetBytes: string | null;
+ valid: boolean;
+ issue?:
+ | 'TRUNCATED_HEADER'
+ | 'LEGACY_FORMAT'
+ | 'INVALID_MAGIC'
+ | 'INVALID_TARGET_SIZE';
+}
+
+function inspectPatch(
+ patchInput: string | BinaryInput,
+ options?: PatchInspectionOptions
+): Promise;
+```
+
+Reads the 24-byte patch header without applying the patch. Pass a native patch
+path on Android/iOS or a `BinaryInput` on Web.
+
+- `declaredTargetBytes` is a decimal string so values above
+ `Number.MAX_SAFE_INTEGER` remain exact.
+- `valid` only establishes structural compatibility. It does not authenticate
+ the patch or prove that its compressed payload is intact.
+- `BSDIFF40` is reported as `LEGACY_FORMAT`, not accepted as
+ `ENDSLEY/BSDIFF43`.
+- `maxInputBytes` bounds the patch file or binary input before its header is
+ inspected.
+
+## `verifyPatch`
+
+```ts
+interface PatchVerificationResult {
+ verified: boolean;
+ restoredBytes: number;
+ expectedBytes: number;
+ patch: PatchMetadata;
+}
+
+// Android / iOS paths
+function verifyPatch(
+ oldFile: string,
+ patchFile: string,
+ expectedFile: string,
+ options?: NativeOperationOptions
+): Promise;
+
+// Web binary values
+function verifyPatch(
+ oldData: BinaryInput,
+ patchData: BinaryInput,
+ expectedData: BinaryInput,
+ options?: BinaryOperationOptions
+): Promise;
+```
+
+Applies the patch and compares the restored result with the expected target
+byte-for-byte.
+
+- A valid match resolves with `verified: true`; a well-formed patch that
+ restores different bytes resolves with `verified: false`.
+- Malformed or incompatible structure rejects with `EPATCH`.
+- Native implementations use a library-owned temporary output and remove it on
+ success, mismatch, or failure. The method never replaces application data.
+- Web verification uses the same Worker/Wasm path as `patchBytes` and honors
+ `AbortSignal` and byte limits.
+- Resource-limit failures from these portable APIs use `ERESOURCE` on every
+ platform.
+
## Web operation options
- `signal` cancels the current Web operation. A call with a signal receives a
@@ -165,7 +248,8 @@ path operations use `startDiff` or `startPatch` for equivalent controls.
All functions remain exported so shared code has one stable import shape.
Calling `diffBytes` or `patchBytes` on native rejects with `EUNSUPPORTED`.
Calling `diff`, `patch`, `startDiff`, or `startPatch` on Web behaves the same
-way.
+way. `inspectPatch` and `verifyPatch` are available on every platform, but they
+require native paths on Android/iOS and binary values on Web.
Importing the Web entry during server-side rendering does not start a Worker.
Calling a binary API without browser Worker support rejects with
@@ -180,22 +264,22 @@ platform can classify the failure.
type PatchError = Error & { code?: string };
```
-| Code | Meaning |
-| -------------- | ----------------------------------------------------------- |
-| `EINVAL` | Empty, duplicate, or invalid input. |
-| `ENOENT` | A required native file does not exist. |
-| `EEXIST` | A native output path already exists. |
-| `EUNSUPPORTED` | The selected API is not available on the current platform. |
-| `EUNAVAILABLE` | The native module worker has already shut down. |
-| `ECANCELLED` | A native job was cooperatively cancelled. |
-| `EINPUT_TOO_LARGE` | A native input exceeded `maxInputBytes`. |
-| `EOUTPUT_TOO_LARGE` | Native generated/restored output exceeded its limit. |
-| `EABORTED` | The Web operation was cancelled through its signal. |
-| `ERESOURCE` | A configured Web input or output byte limit was exceeded. |
-| `EDIFF` | The native diff core rejected or could not write the input. |
-| `EPATCH` | The native patch core rejected a malformed patch or output. |
-| `EWEBASSEMBLY` | WebAssembly loading, patch validation, or execution failed. |
-| `EUNSPECIFIED` | An unclassified native exception occurred. |
+| 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. |
+| `ECANCELLED` | A native job was cooperatively cancelled. |
+| `EINPUT_TOO_LARGE` | A native input exceeded `maxInputBytes`. |
+| `EOUTPUT_TOO_LARGE` | Native generated/restored output exceeded its limit. |
+| `EABORTED` | The Web operation was cancelled through its signal. |
+| `ERESOURCE` | A portable or Web input/output byte limit was exceeded. |
+| `EDIFF` | The native diff core rejected or could not write the input. |
+| `EPATCH` | The native patch core rejected a malformed patch or output. |
+| `EWEBASSEMBLY` | WebAssembly loading, patch validation, or execution failed. |
+| `EUNSPECIFIED` | An unclassified native exception occurred. |
Treat error messages as diagnostic text rather than a stable machine-readable
contract. Branch on `code` when recovery behavior differs.
diff --git a/docs/development.md b/docs/development.md
index ae0bc29..ae2bd8a 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -65,7 +65,7 @@ source-pattern assertions.
`test:native-operations` deterministically covers job progress, cancellation,
limits, malformed patches, atomic destination behavior, and temporary cleanup.
-Run the repeatable Web performance baseline with:
+Run the repeatable Web and native performance baselines with:
```sh
yarn benchmark:web
@@ -74,6 +74,22 @@ yarn benchmark:native
BENCHMARK_OUTPUT=/tmp/native-core.json yarn benchmark:native
```
+Each input size runs in a fresh process. Both reports include peak resident
+memory; the Web report also records the resident, external, and ArrayBuffer
+memory still live after the round trip. Use the non-blocking large-file profile
+before changing buffer ownership or the patch format:
+
+```sh
+BENCHMARK_OUTPUT=/tmp/web-large.json yarn benchmark:large:web
+BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native
+```
+
+The large profile uses 16, 64, and 128 MiB fixtures and can consume several
+gigabytes of memory. It is intentionally not a pull-request gate. A manual
+`Native Core Benchmark` run accepts a comma-separated size list when a shared
+runner baseline is useful. Interpret the numbers with the scope and acceptance
+criteria in the [large-file roadmap](./large-files-v04.md).
+
The published-package canaries install directly from npm and intentionally use
current Vite and Expo toolchains. They are scheduled CI checks, not release
gates. Run them on demand with `yarn test:registry:vite` and
diff --git a/docs/large-files-v04.md b/docs/large-files-v04.md
new file mode 100644
index 0000000..223789d
--- /dev/null
+++ b/docs/large-files-v04.md
@@ -0,0 +1,102 @@
+# Large-file roadmap (v0.4)
+
+This document defines how the project will evaluate larger inputs, expose
+honest progress, and investigate streaming without weakening patch
+compatibility. It is a feasibility and measurement plan, not a promise that
+every browser or mobile device can process a particular file size.
+
+## Current constraints
+
+The current diff algorithm needs random access to the complete old and new
+inputs while building and traversing its suffix array. Native calls therefore
+operate on file paths but still allocate memory proportional to the input. The
+Web implementation additionally moves complete buffers between JavaScript, a
+Worker, and WebAssembly linear memory.
+
+Patch application is less demanding than diff generation, but the current C
+and Web boundaries still materialize the complete operation state. Resource
+limits prevent unbounded work; they do not make the algorithm streaming.
+
+## Measurement matrix
+
+The default 1, 10, and 50 MiB benchmarks remain the inexpensive trend line.
+The explicit large-file profile uses deterministic 16, 64, and 128 MiB inputs
+with one changed byte per 4 KiB:
+
+```sh
+BENCHMARK_OUTPUT=/tmp/web-large.json yarn benchmark:large:web
+BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native
+```
+
+Each size runs in a fresh process so peak resident memory is comparable. Record
+the runtime, diff and patch duration, patch size, round-trip result, and peak
+resident memory. The Web report also records live external and ArrayBuffer
+memory after the round trip. Compare only runs from similar hardware and
+toolchains; shared-runner numbers are diagnostic artifacts, not PR thresholds.
+The large profiling commands preserve per-size failures in the JSON report and
+complete the remaining samples; a recorded report is not itself evidence that
+all sizes passed. Default benchmarks still exit unsuccessfully on any failure.
+
+The profiling run passes when every requested size either completes a verified
+round trip within the host's documented memory budget or fails with a defined
+resource error. A crash, leaked temporary output, corrupted result, or silent
+fallback is a failure. Results do not imply support above the measured size or
+on lower-memory devices.
+
+The initial Apple M3 Pro / Node 22 record is checked in under `benchmarks/`.
+Native completed 128 MiB with approximately 2.37 GiB peak RSS. Web completed
+64 MiB with approximately 2.09 GiB peak RSS, while 128 MiB returned the generic
+`EWEBASSEMBLY` error. That generic failure remains an error-taxonomy gap and
+means the project does not currently claim 128 MiB Web diff support.
+
+## Progress semantics
+
+Progress must be produced by real algorithm checkpoints, never a timer or an
+animation that guesses completion. A future cross-platform operation can use
+the existing stages:
+
+- `reading`: validating inputs and loading the data required by the core.
+- `processing`: suffix-array/diff work or patch reconstruction.
+- `writing`: persisting and atomically committing native output; Web completes
+ this stage when the result buffer is ready to transfer.
+
+Native jobs already expose these stages. Web parity requires Worker messages
+emitted from instrumented C/WebAssembly boundaries. Until those checkpoints
+exist, Web should report only start, cancellation, and completion rather than
+synthetic percentages. The public callback remains optional and must not change
+the result or error behavior when it is absent.
+
+## Streaming feasibility
+
+True streaming diff is not a compatible optimization of the current BSDiff
+algorithm: suffix-array construction and matching require global random access
+to both inputs. Supporting it would mean selecting a different algorithm or a
+new patch format, with an explicit compatibility and migration decision.
+
+Patch application is a better candidate for incremental work. A prototype can
+read the old file and compressed control/diff/extra streams in bounded chunks,
+write a temporary destination, and retain the current `ENDSLEY/BSDIFF43`
+contract. Browser support should start with `Blob`/`File` and an internal
+bounded reader; writable file handles can remain a progressive enhancement.
+
+## Delivery sequence
+
+1. Keep 16/64/128 MiB time and peak-memory baselines for native and Web.
+2. Instrument core checkpoints and add truthful Web progress events without
+ changing the existing `diff`, `patch`, or `startPatch` contracts.
+3. Prototype file-backed, incremental patch application and prove cancellation,
+ resource limits, temporary cleanup, and byte-for-byte compatibility.
+4. Decide whether the measured benefit justifies a new public API. Treat a
+ streaming diff algorithm or new patch format as a separate proposal.
+
+Any production API must preserve deterministic output validation, reject sizes
+outside configured limits before large allocations where possible, clean up on
+cancellation and failure, and pass Android API 24, iOS Simulator, browser, and
+cross-platform golden-patch tests.
+
+## Non-goals
+
+- Claiming that a single measured desktop result is a mobile support guarantee.
+- Advertising inputs larger than 128 MiB without a new measurement record.
+- Uploading user files to a hosted service; the site toolkit remains local-only.
+- Introducing simulated progress merely to make a long operation look active.
diff --git a/docs/platform-support.md b/docs/platform-support.md
index 7b22b14..68968f6 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -2,16 +2,18 @@
## Capability matrix
-| Capability | Android | iOS | React Native Web |
-| ------------------------------ | ------------------ | --------------------- | ------------------ |
-| File-path APIs | Yes | Yes | No |
-| Binary-data APIs | No | No | Yes |
-| Progress / cooperative cancel | Native jobs | Native jobs | `AbortSignal` |
-| Input / output limits | Native jobs | Native jobs | Binary options |
-| Legacy bridge | Yes | Yes | N/A |
-| TurboModule / New Architecture | Yes | Yes | N/A |
-| Background execution | Serial executor | Serialized worker | Module Web Worker |
-| Patch format | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` |
+| Capability | Android | iOS | React Native Web |
+| ------------------------------ | ------------------ | ------------------ | ------------------ |
+| File-path APIs | Yes | Yes | No |
+| Binary-data APIs | No | No | Yes |
+| Progress / cooperative cancel | Native jobs | Native jobs | `AbortSignal` |
+| Input / output limits | Native jobs | Native jobs | Binary options |
+| Patch metadata | File path | File path | Binary input |
+| Byte-for-byte verification | Temporary file | Temporary file | In-memory result |
+| Legacy bridge | Yes | Yes | N/A |
+| TurboModule / New Architecture | Yes | Yes | N/A |
+| Background execution | Serial executor | Serialized worker | Module Web Worker |
+| Patch format | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` | `ENDSLEY/BSDIFF43` |
The example application uses React Native 0.86.0 and exercises the New
Architecture on Android API 24/31 and iOS Simulator. Direct compatibility
@@ -33,6 +35,9 @@ Native minor version:
Native operations run on a module-owned single-thread executor. Jobs add a
registry for queued/active cancellation, rate-limited progress events, limits,
and cleanup. The packaged C code is built with CMake and invoked through JNI.
+`inspectPatch` reads only the patch header. `verifyPatch` applies into a cache
+file, compares it with the expected path in bounded chunks, and removes the
+temporary output before resolving.
## iOS
@@ -45,6 +50,8 @@ following cancellation request is handled. Patch work runs asynchronously on a
separate serial worker queue, keeping the bridge responsive while preserving the
shared C library's single-operation boundary. The job registry is cancelled and
cleaned when the module is invalidated.
+Metadata inspection reads only 24 header bytes. Verification writes to a unique
+file under the system temporary directory and removes it on every exit path.
## React Native Web
@@ -75,6 +82,9 @@ Calls without an `AbortSignal` share a module Worker and initialized
WebAssembly module. Calls with a signal receive a dedicated Worker so
cancellation is isolated to that operation. Both paths serialize work inside
their Worker; callers should still enforce an application memory budget.
+`inspectPatch` does not start a Worker. `verifyPatch` first validates metadata,
+then uses the same Worker path as `patchBytes` and discards the restored buffer
+after comparing it with the expected input.
## Server-side rendering
diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md
index 4a9d7d7..50233fd 100644
--- a/docs/zh-CN/README.md
+++ b/docs/zh-CN/README.md
@@ -25,5 +25,7 @@
- [架构](/docs/zh-CN/architecture/) — 执行路径与补丁兼容性。
- [可控制的原生操作](/docs/zh-CN/native-operations-v03/) — 资源限制、取消、进度与
原子输出约定。
+- [大文件演进路线](/docs/zh-CN/large-files-v04/) — 下一阶段的内存基线、真实进度和
+ 流式能力可行性。
- [常见问题与排障](/docs/zh-CN/troubleshooting/) — 常见集成失败。
- [开发与验证](/docs/zh-CN/development/) — 本地构建、测试、WASM 与发布检查。
diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md
index 0ae7c03..d3dbd2f 100644
--- a/docs/zh-CN/api-reference.md
+++ b/docs/zh-CN/api-reference.md
@@ -11,8 +11,12 @@ import {
startPatch,
diffBytes,
patchBytes,
+ inspectPatch,
+ verifyPatch,
type BinaryInput,
type BinaryOperationOptions,
+ type PatchMetadata,
+ type PatchVerificationResult,
} from 'react-native-bs-diff-patch';
```
@@ -139,6 +143,79 @@ function patchBytes(
- 不会修改 `oldData` 或 `patchData`。
- 当补丁头声明的输出超过 `maxOutputBytes` 时,会在分配输出前拒绝。
+## `inspectPatch`
+
+```ts
+interface PatchInspectionOptions {
+ maxInputBytes?: number;
+}
+
+interface PatchMetadata {
+ format: 'ENDSLEY/BSDIFF43' | 'BSDIFF40' | 'UNKNOWN';
+ patchBytes: number;
+ headerBytes: number;
+ payloadBytes: number;
+ declaredTargetBytes: string | null;
+ valid: boolean;
+ issue?:
+ | 'TRUNCATED_HEADER'
+ | 'LEGACY_FORMAT'
+ | 'INVALID_MAGIC'
+ | 'INVALID_TARGET_SIZE';
+}
+
+function inspectPatch(
+ patchInput: string | BinaryInput,
+ options?: PatchInspectionOptions
+): Promise;
+```
+
+不应用补丁,只读取 24 字节补丁头。Android/iOS 传入补丁路径,Web 传入
+`BinaryInput`。
+
+- `declaredTargetBytes` 使用十进制字符串,避免超过 `Number.MAX_SAFE_INTEGER`
+ 后丢失精度。
+- `valid` 只表示结构兼容,不能认证补丁,也不能证明压缩负载完整。
+- `BSDIFF40` 会报告为 `LEGACY_FORMAT`,不会被当作
+ `ENDSLEY/BSDIFF43` 接受。
+- `maxInputBytes` 在读取补丁头前限制原生文件或 Web 二进制输入。
+
+## `verifyPatch`
+
+```ts
+interface PatchVerificationResult {
+ verified: boolean;
+ restoredBytes: number;
+ expectedBytes: number;
+ patch: PatchMetadata;
+}
+
+// Android / iOS 文件路径
+function verifyPatch(
+ oldFile: string,
+ patchFile: string,
+ expectedFile: string,
+ options?: NativeOperationOptions
+): Promise;
+
+// Web 二进制值
+function verifyPatch(
+ oldData: BinaryInput,
+ patchData: BinaryInput,
+ expectedData: BinaryInput,
+ options?: BinaryOperationOptions
+): Promise;
+```
+
+应用补丁,并将还原结果与预期目标逐字节比较。
+
+- 完全一致时返回 `verified: true`;结构有效但还原内容不同时返回
+ `verified: false`。
+- 补丁结构损坏或不兼容时以 `EPATCH` 拒绝。
+- 原生实现使用库管理的临时输出,并在成功、不匹配或失败后清理;不会替换业务文件。
+- Web 使用与 `patchBytes` 相同的 Worker/Wasm 路径,并支持 `AbortSignal` 和字节限制。
+- 这些跨平台 API 的资源限制错误在所有平台统一为 `ERESOURCE`。
+
## Web 操作选项
- `signal` 取消当前 Web 操作。带 signal 的调用使用专用 Worker,因此取消不会中断
@@ -155,7 +232,8 @@ function patchBytes(
所有函数始终导出,以便共享代码保持稳定导入形式。在原生端调用 `diffBytes` 或
`patchBytes`,以及在 Web 调用 `diff`、`patch`、`startDiff` 或 `startPatch`,
-都会以 `EUNSUPPORTED` 拒绝。
+都会以 `EUNSUPPORTED` 拒绝。`inspectPatch` 与 `verifyPatch` 在所有平台可用,但
+Android/iOS 必须传文件路径,Web 必须传二进制值。
SSR 阶段导入 Web 入口不会启动 Worker;在没有浏览器 Worker 的环境调用二进制
API 会以 `EUNSUPPORTED` 拒绝。
@@ -168,22 +246,22 @@ API 会以 `EUNSUPPORTED` 拒绝。
type PatchError = Error & { code?: string };
```
-| 错误码 | 含义 |
-| -------------- | ----------------------------------------- |
-| `EINVAL` | 输入为空、重复或类型无效。 |
-| `ENOENT` | 原生端所需文件不存在。 |
-| `EEXIST` | 原生端输出路径已经存在。 |
-| `EUNSUPPORTED` | 当前平台不支持所选 API。 |
-| `EUNAVAILABLE` | 原生模块工作队列已经关闭。 |
-| `ECANCELLED` | 原生 job 被协作式取消。 |
-| `EINPUT_TOO_LARGE` | 原生输入超过 `maxInputBytes`。 |
-| `EOUTPUT_TOO_LARGE` | 原生生成或还原输出超过配置上限。 |
-| `EABORTED` | Web 操作被传入的 signal 取消。 |
-| `ERESOURCE` | 超过配置的 Web 输入或输出字节上限。 |
-| `EDIFF` | 原生 diff 核心拒绝输入或无法写入补丁。 |
-| `EPATCH` | 原生 patch 核心拒绝损坏补丁或输出失败。 |
-| `EWEBASSEMBLY` | Worker、补丁校验或 WebAssembly 执行失败。 |
-| `EUNSPECIFIED` | 未分类的原生异常。 |
+| 错误码 | 含义 |
+| ------------------- | ------------------------------------------ |
+| `EINVAL` | 输入为空、重复或类型无效。 |
+| `ENOENT` | 原生端所需文件不存在。 |
+| `EEXIST` | 原生端输出路径已经存在。 |
+| `EUNSUPPORTED` | 当前平台不支持所选 API。 |
+| `EUNAVAILABLE` | 原生模块工作队列已经关闭。 |
+| `ECANCELLED` | 原生 job 被协作式取消。 |
+| `EINPUT_TOO_LARGE` | 原生输入超过 `maxInputBytes`。 |
+| `EOUTPUT_TOO_LARGE` | 原生生成或还原输出超过配置上限。 |
+| `EABORTED` | Web 操作被传入的 signal 取消。 |
+| `ERESOURCE` | 超过跨平台或 Web API 的输入/输出字节上限。 |
+| `EDIFF` | 原生 diff 核心拒绝输入或无法写入补丁。 |
+| `EPATCH` | 原生 patch 核心拒绝损坏补丁或输出失败。 |
+| `EWEBASSEMBLY` | Worker、补丁校验或 WebAssembly 执行失败。 |
+| `EUNSPECIFIED` | 未分类的原生异常。 |
错误消息仅用于诊断,不是稳定的机器可读约定。恢复策略不同时应根据 `code` 分支。
diff --git a/docs/zh-CN/development.md b/docs/zh-CN/development.md
index 3dd3894..fa8fef7 100644
--- a/docs/zh-CN/development.md
+++ b/docs/zh-CN/development.md
@@ -62,7 +62,7 @@ React Native artifact 直接编译真实 Android 模块源码,不依赖源码
`test:native-operations` 会确定性覆盖 job 进度、取消、限制、畸形补丁、原子目标行为
和临时文件清理。
-可复现的 Web 性能基准命令:
+可复现的 Web 与原生性能基准命令:
```sh
yarn benchmark:web
@@ -71,6 +71,20 @@ yarn benchmark:native
BENCHMARK_OUTPUT=/tmp/native-core.json yarn benchmark:native
```
+每个输入尺寸都在独立进程中运行。两份报告都会记录峰值常驻内存;Web 报告还会记录
+往返结束后仍存活的常驻、external 和 ArrayBuffer 内存。修改 buffer 所有权或补丁
+格式前,可运行不阻塞 PR 的大文件 profiling:
+
+```sh
+BENCHMARK_OUTPUT=/tmp/web-large.json yarn benchmark:large:web
+BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native
+```
+
+大文件 profiling 使用 16、64、128 MiB fixture,可能消耗数 GiB 内存,因此不会作为
+pull request 门禁。手动运行 `Native Core Benchmark` 时可以传入逗号分隔的尺寸列表,
+以获得共享 Runner 基线。解读结果时应遵循[大文件演进路线](/docs/zh-CN/large-files-v04/)
+中的范围和验收标准。
+
发布包 canary 会直接从 npm 安装,并有意使用当前 Vite 与 Expo 工具链;它们是定时
CI,不是发版门禁。可用 `yarn test:registry:vite` 和 `yarn test:registry:expo`
手动执行,并通过 `PACKAGE_SPEC` 验证指定 tag 或 tarball。
diff --git a/docs/zh-CN/large-files-v04.md b/docs/zh-CN/large-files-v04.md
new file mode 100644
index 0000000..d4e85bb
--- /dev/null
+++ b/docs/zh-CN/large-files-v04.md
@@ -0,0 +1,80 @@
+# 大文件演进路线(v0.4)
+
+本文定义项目如何评估更大输入、提供可信进度并研究流式处理,同时不削弱补丁兼容性。
+它是一份可行性与测量计划,不承诺所有浏览器或移动设备都能处理某个固定尺寸。
+
+## 当前约束
+
+当前 diff 算法在构建和遍历后缀数组时,需要随机访问完整的旧、新输入。原生调用虽然
+接收文件路径,内存使用仍然与输入规模成比例。Web 实现还需要在 JavaScript、Worker
+与 WebAssembly 线性内存之间传递完整 buffer。
+
+应用补丁比生成 diff 的要求低,但当前 C 和 Web 边界仍会把完整操作状态放入内存。
+资源限制可以阻止无界工作,但不会让算法自动变成流式处理。
+
+## 测量矩阵
+
+默认 1、10、50 MiB 基准继续作为成本较低的趋势线。显式的大文件 profiling 使用
+16、64、128 MiB 确定性输入,每 4 KiB 修改一个字节:
+
+```sh
+BENCHMARK_OUTPUT=/tmp/web-large.json yarn benchmark:large:web
+BENCHMARK_OUTPUT=/tmp/native-large.json yarn benchmark:large:native
+```
+
+每个尺寸在独立进程运行,以便比较峰值常驻内存。需要记录 runtime、diff 和 patch
+耗时、补丁尺寸、往返结果与峰值常驻内存。Web 报告还记录往返结束后的 external 与
+ArrayBuffer 存活内存。只比较相近硬件和工具链上的结果;共享 Runner 数据只作为诊断
+artifact,不作为 PR 阈值。
+大文件 profiling 命令会把单个尺寸的失败保留在 JSON 报告中并继续其他样本;生成了
+报告不代表全部尺寸通过。默认基准遇到任何失败时仍以失败状态退出。
+
+当每个请求尺寸都在宿主机记录的内存预算内完成并验证往返,或以明确的资源错误失败
+时,profiling 才算通过。崩溃、临时输出泄漏、结果损坏或静默降级都属于失败。测量
+结果不代表更大尺寸或更低内存设备也获得支持。
+
+首份 Apple M3 Pro / Node 22 记录已检入 `benchmarks/`。原生端以约 2.37 GiB 峰值 RSS
+完成 128 MiB;Web 端以约 2.09 GiB 峰值 RSS 完成 64 MiB,但 128 MiB 返回通用
+`EWEBASSEMBLY`。这一通用失败仍是错误分类缺口,因此项目目前不宣称 Web diff 支持
+128 MiB。
+
+## 进度语义
+
+进度必须来自真实算法检查点,不能使用定时器或动画猜测完成比例。未来的跨平台操作
+可以沿用现有阶段:
+
+- `reading`:验证输入并加载核心需要的数据。
+- `processing`:执行后缀数组/diff 工作或重建补丁。
+- `writing`:原生端持久化并原子提交输出;Web 端在结果 buffer 可以传输时完成此阶段。
+
+原生 job 已暴露这些阶段。Web 对齐需要从插桩后的 C/WebAssembly 边界发出 Worker
+消息。在这些检查点出现之前,Web 只应报告开始、取消和完成,不能提供虚假百分比。
+公开 callback 保持可选,未传入时不得改变结果或错误行为。
+
+## 流式处理可行性
+
+真正的流式 diff 不是当前 BSDiff 算法的兼容优化:后缀数组构建和匹配需要全局随机
+访问两份输入。要实现它,必须选择另一种算法或新补丁格式,并明确兼容与迁移策略。
+
+补丁应用更适合增量处理。原型可以按有界 chunk 读取旧文件和压缩后的
+control/diff/extra 流,写入临时目标,同时保留 `ENDSLEY/BSDIFF43` 约定。浏览器应先
+支持 `Blob`/`File` 和内部有界 reader;可写文件句柄继续作为渐进增强能力。
+
+## 实施顺序
+
+1. 保持原生与 Web 的 16/64/128 MiB 耗时和峰值内存基线。
+2. 为核心检查点插桩,增加真实的 Web 进度事件,不改变现有 `diff`、`patch` 或
+ `startPatch` 约定。
+3. 验证文件后备、增量应用补丁的原型,并证明取消、资源限制、临时清理和逐字节兼容。
+4. 根据实测收益决定是否值得新增公开 API;流式 diff 算法或新补丁格式另立提案。
+
+所有生产 API 都必须保持确定性输出验证,在大额分配前尽可能拒绝超限尺寸,在取消和
+失败时清理资源,并通过 Android API 24、iOS Simulator、浏览器与跨平台 golden patch
+测试。
+
+## 非目标
+
+- 把单次桌面测量结果宣传为移动端支持保证。
+- 没有新测量记录就宣称支持超过 128 MiB 的输入。
+- 将用户文件上传到托管服务;站点工具继续只在本地处理。
+- 为了让耗时操作看起来有响应而引入模拟进度。
diff --git a/docs/zh-CN/platform-support.md b/docs/zh-CN/platform-support.md
index 0ecb210..f29cef0 100644
--- a/docs/zh-CN/platform-support.md
+++ b/docs/zh-CN/platform-support.md
@@ -2,16 +2,18 @@
## 能力矩阵
-| 能力 | Android | iOS | React Native Web |
-| -------------------- | ------------------ | -------- | ---------------- |
-| 文件路径 API | 支持 | 支持 | 不支持 |
-| 二进制数据 API | 不支持 | 不支持 | 支持 |
-| 进度 / 协作式取消 | 原生 job | 原生 job | `AbortSignal` |
-| 输入 / 输出限制 | 原生 job | 原生 job | 二进制 options |
-| 旧桥接架构 | 支持 | 支持 | 不适用 |
-| TurboModule / 新架构 | 支持 | 支持 | 不适用 |
+| 能力 | Android | iOS | React Native Web |
+| -------------------- | ------------------ | ----------- | ---------------- |
+| 文件路径 API | 支持 | 支持 | 不支持 |
+| 二进制数据 API | 不支持 | 不支持 | 支持 |
+| 进度 / 协作式取消 | 原生 job | 原生 job | `AbortSignal` |
+| 输入 / 输出限制 | 原生 job | 原生 job | 二进制 options |
+| 补丁元数据 | 文件路径 | 文件路径 | 二进制输入 |
+| 逐字节验证 | 临时文件 | 临时文件 | 内存结果 |
+| 旧桥接架构 | 支持 | 支持 | 不适用 |
+| TurboModule / 新架构 | 支持 | 支持 | 不适用 |
| 后台执行 | 串行 executor | 串行 worker | 模块 Web Worker |
-| 补丁格式 | `ENDSLEY/BSDIFF43` | 同左 | 同左 |
+| 补丁格式 | `ENDSLEY/BSDIFF43` | 同左 | 同左 |
示例应用使用 React Native 0.86.0,并在 Android API 24/31 与 iOS Simulator
验证新架构运行时。直接兼容 fixture 会针对 React Native 0.73.11、0.74.7 编译
@@ -29,6 +31,8 @@ Android 根据 React Native 次版本选择新架构包实现:
原生操作运行在模块自有的单线程 executor 上。job registry 负责排队/运行中取消、
限频进度、资源限制和清理。项目内置 C 代码通过 CMake 构建,并由 JNI 调用。
+`inspectPatch` 只读取补丁头;`verifyPatch` 将结果写入缓存临时文件,分块比较预期
+路径,并在返回前删除临时输出。
## iOS
@@ -39,6 +43,8 @@ TurboModule 实例。
模块方法使用串行 dispatch queue,确保后续取消请求到达时 job 已经登记。耗时补丁
操作会异步进入独立的串行工作队列,在保持桥接响应的同时维护共享 C 核心的单操作
边界。模块失效时会取消并清理 job registry。
+元数据检查只读取 24 字节补丁头。验证使用系统临时目录中的唯一文件,并在所有退出
+路径上清理。
## React Native Web
@@ -67,6 +73,8 @@ Web 入口面向浏览器,不是 Node.js 文件系统适配器;它不会在
未传 `AbortSignal` 的调用共用模块 Worker 与已初始化的 WebAssembly 模块;带
signal 的调用使用专用 Worker,保证取消只影响当前任务。两种路径都会在各自 Worker
内串行执行,但调用方仍应设置应用级内存预算。
+`inspectPatch` 不会启动 Worker。`verifyPatch` 先验证元数据,再复用 `patchBytes`
+的 Worker 路径,并在与预期输入比较后丢弃还原缓冲区。
## 服务端渲染
diff --git a/example/src/App.tsx b/example/src/App.tsx
index a0316af..6068be3 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -3,10 +3,12 @@ import * as React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import {
diff,
+ inspectPatch,
patch,
startDiff,
startPatch,
type NativeOperationProgress,
+ verifyPatch,
} from 'react-native-bs-diff-patch';
import * as FS from 'react-native-fs';
@@ -112,6 +114,43 @@ export default function App() {
crossPlatformFixture.patchBase64,
'base64'
);
+ const goldenNewFileInfo = await FS.stat(goldenNewFile);
+ const goldenMetadata = await inspectPatch(goldenPatchFile, {
+ maxInputBytes: 1024 * 1024,
+ });
+ if (
+ !goldenMetadata.valid ||
+ goldenMetadata.format !== 'ENDSLEY/BSDIFF43' ||
+ goldenMetadata.declaredTargetBytes !== String(goldenNewFileInfo.size)
+ ) {
+ throw new Error('native patch metadata did not match the fixture');
+ }
+ const goldenVerification = await verifyPatch(
+ goldenOldFile,
+ goldenPatchFile,
+ goldenNewFile,
+ {
+ maxInputBytes: 1024 * 1024,
+ maxOutputBytes: 1024 * 1024,
+ }
+ );
+ if (
+ !goldenVerification.verified ||
+ goldenVerification.patch.declaredTargetBytes !==
+ String(goldenNewFileInfo.size)
+ ) {
+ throw new Error(
+ 'native patch verification did not match the fixture'
+ );
+ }
+ const mismatchVerification = await verifyPatch(
+ goldenOldFile,
+ goldenPatchFile,
+ oldFile
+ );
+ if (mismatchVerification.verified) {
+ throw new Error('native patch verification accepted a wrong target');
+ }
await diff(goldenOldFile, goldenNewFile, generatedGoldenPatchFile);
const generatedGoldenPatch = await FS.readFile(
generatedGoldenPatchFile,
@@ -137,6 +176,44 @@ export default function App() {
'bm90IGEgYnNkaWZmIHBhdGNo',
'base64'
);
+ const corruptMetadata = await inspectPatch(corruptPatchFile);
+ if (
+ corruptMetadata.valid ||
+ corruptMetadata.issue !== 'TRUNCATED_HEADER'
+ ) {
+ throw new Error('native patch inspection accepted a corrupt patch');
+ }
+ const corruptVerificationCode = await verifyPatch(
+ goldenOldFile,
+ corruptPatchFile,
+ goldenNewFile
+ ).then(
+ () => undefined,
+ (error: { code?: string }) => error.code
+ );
+ if (corruptVerificationCode !== 'EPATCH') {
+ throw new Error(
+ `corrupt verification should reject with EPATCH, got ${String(
+ corruptVerificationCode
+ )}`
+ );
+ }
+ const verificationLimitCode = await verifyPatch(
+ goldenOldFile,
+ goldenPatchFile,
+ goldenNewFile,
+ { maxOutputBytes: 1 }
+ ).then(
+ () => undefined,
+ (error: { code?: string }) => error.code
+ );
+ if (verificationLimitCode !== 'ERESOURCE') {
+ throw new Error(
+ `verification limit should reject with ERESOURCE, got ${String(
+ verificationLimitCode
+ )}`
+ );
+ }
let corruptPatchErrorCode: string | undefined;
try {
await patch(goldenOldFile, corruptOutputFile, corruptPatchFile);
diff --git a/ios/BsDiffPatch.mm b/ios/BsDiffPatch.mm
index 88567e1..8982bd0 100644
--- a/ios/BsDiffPatch.mm
+++ b/ios/BsDiffPatch.mm
@@ -170,6 +170,123 @@ - (NSString *)normalizedPath:(NSString *)path
return [path hasPrefix:@"file://"] ? [path substringFromIndex:7] : path;
}
+- (NSDictionary *)patchMetadataAtPath:(NSString *)patchFile
+ error:(NSError **)error
+{
+ NSFileManager *fileManager = [NSFileManager defaultManager];
+ NSDictionary *attributes = [fileManager attributesOfItemAtPath:patchFile error:error];
+ if (attributes == nil) return nil;
+
+ unsigned long long patchBytes = [attributes fileSize];
+ NSFileHandle *handle = [NSFileHandle fileHandleForReadingAtPath:patchFile];
+ if (handle == nil) {
+ if (error != nullptr) {
+ *error = [NSError errorWithDomain:@"BsDiffPatch"
+ code:1
+ userInfo:@{NSLocalizedDescriptionKey : @"could not read patchFile"}];
+ }
+ return nil;
+ }
+ NSData *header = [handle readDataOfLength:24];
+ [handle closeFile];
+ const uint8_t *bytes = static_cast(header.bytes);
+ NSUInteger headerBytes = header.length;
+ NSString *legacyMagic = [[NSString alloc]
+ initWithBytes:bytes
+ length:MIN((NSUInteger)8, headerBytes)
+ encoding:NSASCIIStringEncoding] ?: @"";
+ NSString *currentMagic = [[NSString alloc]
+ initWithBytes:bytes
+ length:MIN((NSUInteger)16, headerBytes)
+ encoding:NSASCIIStringEncoding] ?: @"";
+
+ NSString *format = @"UNKNOWN";
+ NSString *issue = nil;
+ NSString *declaredTargetBytes = nil;
+ BOOL valid = NO;
+ if (headerBytes < 24) {
+ if ([legacyMagic isEqualToString:@"BSDIFF40"]) {
+ format = @"BSDIFF40";
+ issue = @"LEGACY_FORMAT";
+ } else {
+ issue = @"TRUNCATED_HEADER";
+ }
+ } else if (![currentMagic isEqualToString:@"ENDSLEY/BSDIFF43"]) {
+ if ([legacyMagic isEqualToString:@"BSDIFF40"]) {
+ format = @"BSDIFF40";
+ issue = @"LEGACY_FORMAT";
+ } else {
+ issue = @"INVALID_MAGIC";
+ }
+ } else if ((bytes[23] & 0x80) != 0) {
+ format = @"ENDSLEY/BSDIFF43";
+ issue = @"INVALID_TARGET_SIZE";
+ } else {
+ unsigned long long targetBytes = 0;
+ for (NSInteger index = 23; index >= 16; index--) {
+ targetBytes = targetBytes * 256 + bytes[index];
+ }
+ format = @"ENDSLEY/BSDIFF43";
+ declaredTargetBytes = [NSString stringWithFormat:@"%llu", targetBytes];
+ valid = YES;
+ }
+
+ NSMutableDictionary *metadata = [@{
+ @"format" : format,
+ @"patchBytes" : @(patchBytes),
+ @"headerBytes" : @(headerBytes),
+ @"payloadBytes" : @(patchBytes > 24 ? patchBytes - 24 : 0),
+ @"declaredTargetBytes" : declaredTargetBytes ?: [NSNull null],
+ @"valid" : @(valid)
+ } mutableCopy];
+ if (issue != nil) metadata[@"issue"] = issue;
+ return metadata;
+}
+
+- (NSString *)JSONStringForObject:(id)object error:(NSError **)error
+{
+ NSData *data = [NSJSONSerialization dataWithJSONObject:object options:0 error:error];
+ if (data == nil) return nil;
+ return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+}
+
+- (BOOL)fileAtPath:(NSString *)firstPath equalsFileAtPath:(NSString *)secondPath
+{
+ NSDictionary *firstAttributes = [[NSFileManager defaultManager]
+ attributesOfItemAtPath:firstPath
+ error:nil];
+ NSDictionary *secondAttributes = [[NSFileManager defaultManager]
+ attributesOfItemAtPath:secondPath
+ error:nil];
+ if (firstAttributes == nil || secondAttributes == nil ||
+ [firstAttributes fileSize] != [secondAttributes fileSize]) {
+ return NO;
+ }
+
+ NSFileHandle *first = [NSFileHandle fileHandleForReadingAtPath:firstPath];
+ NSFileHandle *second = [NSFileHandle fileHandleForReadingAtPath:secondPath];
+ if (first == nil || second == nil) {
+ [first closeFile];
+ [second closeFile];
+ return NO;
+ }
+ BOOL equal = YES;
+ while (YES) {
+ @autoreleasepool {
+ NSData *firstChunk = [first readDataOfLength:64 * 1024];
+ NSData *secondChunk = [second readDataOfLength:64 * 1024];
+ if (![firstChunk isEqualToData:secondChunk]) {
+ equal = NO;
+ break;
+ }
+ if (firstChunk.length == 0) break;
+ }
+ }
+ [first closeFile];
+ [second closeFile];
+ return equal;
+}
+
- (BOOL)validateOldFile:(NSString *)oldFile
newFile:(NSString *)newFile
patchFile:(NSString *)patchFile
@@ -399,6 +516,166 @@ - (void)runJob:(NSString *)operation
});
}
+RCT_EXPORT_METHOD(inspectPatch:(NSString *)patchFile
+ maxInputBytes:(double)maxInputBytes
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+{
+ if (patchFile == nil || patchFile.length == 0) {
+ reject(@"EINVAL", @"patchFile should not be nil or empty", nil);
+ return;
+ }
+ if (![self validateLimit:maxInputBytes name:@"maxInputBytes" reject:reject]) return;
+ patchFile = [self normalizedPath:patchFile];
+ if (![[NSFileManager defaultManager] fileExistsAtPath:patchFile]) {
+ reject(@"ENOENT", [NSString stringWithFormat:@"patchFile: %@ does not exist", patchFile], nil);
+ return;
+ }
+
+ dispatch_async(operationQueue(), ^{
+ NSError *error = nil;
+ NSDictionary *attributes = [[NSFileManager defaultManager]
+ attributesOfItemAtPath:patchFile
+ error:&error];
+ if (attributes == nil) {
+ reject(@"ENOENT", error.localizedDescription ?: @"could not inspect patchFile", error);
+ return;
+ }
+ if (maxInputBytes > 0 && [attributes fileSize] > (unsigned long long)maxInputBytes) {
+ reject(
+ @"ERESOURCE",
+ [NSString stringWithFormat:
+ @"patchData is %llu bytes and exceeds the configured %.0f byte limit",
+ [attributes fileSize],
+ maxInputBytes],
+ nil);
+ return;
+ }
+ NSDictionary *metadata = [self patchMetadataAtPath:patchFile error:&error];
+ NSString *json = metadata == nil
+ ? nil
+ : [self JSONStringForObject:metadata error:&error];
+ if (json == nil) {
+ reject(@"EUNSPECIFIED", error.localizedDescription ?: @"could not encode patch metadata", error);
+ return;
+ }
+ resolve(json);
+ });
+}
+
+RCT_EXPORT_METHOD(verifyPatch:(NSString *)oldFile
+ patchFile:(NSString *)patchFile
+ expectedFile:(NSString *)expectedFile
+ maxInputBytes:(double)maxInputBytes
+ maxOutputBytes:(double)maxOutputBytes
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject)
+{
+ if (oldFile == nil || oldFile.length == 0 ||
+ patchFile == nil || patchFile.length == 0 ||
+ expectedFile == nil || expectedFile.length == 0) {
+ reject(@"EINVAL", @"oldFile, patchFile, and expectedFile should not be nil or empty", nil);
+ return;
+ }
+ if (![self validateLimit:maxInputBytes name:@"maxInputBytes" reject:reject] ||
+ ![self validateLimit:maxOutputBytes name:@"maxOutputBytes" reject:reject]) {
+ return;
+ }
+ oldFile = [self normalizedPath:oldFile];
+ patchFile = [self normalizedPath:patchFile];
+ expectedFile = [self normalizedPath:expectedFile];
+ if ([oldFile isEqualToString:patchFile] ||
+ [oldFile isEqualToString:expectedFile] ||
+ [patchFile isEqualToString:expectedFile]) {
+ reject(@"EINVAL", @"oldFile, patchFile, and expectedFile should not be the same", nil);
+ return;
+ }
+ NSFileManager *fileManager = [NSFileManager defaultManager];
+ for (NSString *path in @[ oldFile, patchFile, expectedFile ]) {
+ if (![fileManager fileExistsAtPath:path]) {
+ reject(@"ENOENT", [NSString stringWithFormat:@"input: %@ does not exist", path], nil);
+ return;
+ }
+ }
+
+ dispatch_async(operationQueue(), ^{
+ NSError *error = nil;
+ NSDictionary *oldAttributes = [fileManager attributesOfItemAtPath:oldFile error:&error];
+ NSDictionary *patchAttributes = [fileManager attributesOfItemAtPath:patchFile error:&error];
+ NSDictionary *expectedAttributes = [fileManager attributesOfItemAtPath:expectedFile error:&error];
+ if (oldAttributes == nil || patchAttributes == nil || expectedAttributes == nil) {
+ reject(@"ENOENT", error.localizedDescription ?: @"could not read input metadata", error);
+ return;
+ }
+ if (maxInputBytes > 0 &&
+ ([oldAttributes fileSize] > (unsigned long long)maxInputBytes ||
+ [patchAttributes fileSize] > (unsigned long long)maxInputBytes ||
+ [expectedAttributes fileSize] > (unsigned long long)maxInputBytes)) {
+ reject(@"ERESOURCE", @"verification input exceeds maxInputBytes", nil);
+ return;
+ }
+
+ NSDictionary *metadata = [self patchMetadataAtPath:patchFile error:&error];
+ if (metadata == nil) {
+ reject(@"EPATCH", error.localizedDescription ?: @"could not inspect patchFile", error);
+ return;
+ }
+ if (![metadata[@"valid"] boolValue]) {
+ reject(
+ @"EPATCH",
+ [NSString stringWithFormat:
+ @"patch structure is invalid: %@",
+ metadata[@"issue"] ?: @"UNKNOWN"],
+ nil);
+ return;
+ }
+ unsigned long long declaredTargetBytes =
+ [metadata[@"declaredTargetBytes"] longLongValue];
+ if (maxOutputBytes > 0 && declaredTargetBytes > (unsigned long long)maxOutputBytes) {
+ reject(@"ERESOURCE", @"declared output exceeds maxOutputBytes", nil);
+ return;
+ }
+
+ NSString *outputFile = [NSTemporaryDirectory()
+ stringByAppendingPathComponent:[NSString stringWithFormat:
+ @"bsdiffpatch-verify-%@.tmp",
+ [NSUUID UUID].UUIDString]];
+ int result = bsdiffpatch::patchFile(
+ [oldFile UTF8String], [outputFile UTF8String], [patchFile UTF8String]);
+ if (result != 0) {
+ [fileManager removeItemAtPath:outputFile error:nil];
+ reject(@"EPATCH", [NSString stringWithFormat:@"patch verification failed with native result %d", result], nil);
+ return;
+ }
+
+ NSDictionary *outputAttributes = [fileManager attributesOfItemAtPath:outputFile error:&error];
+ if (outputAttributes == nil) {
+ [fileManager removeItemAtPath:outputFile error:nil];
+ reject(@"EPATCH", error.localizedDescription ?: @"could not read restored output", error);
+ return;
+ }
+ if (maxOutputBytes > 0 && [outputAttributes fileSize] > (unsigned long long)maxOutputBytes) {
+ [fileManager removeItemAtPath:outputFile error:nil];
+ reject(@"ERESOURCE", @"restored output exceeds maxOutputBytes", nil);
+ return;
+ }
+ BOOL verified = [self fileAtPath:outputFile equalsFileAtPath:expectedFile];
+ NSDictionary *verification = @{
+ @"verified" : @(verified),
+ @"restoredBytes" : @([outputAttributes fileSize]),
+ @"expectedBytes" : @([expectedAttributes fileSize]),
+ @"patch" : metadata
+ };
+ NSString *json = [self JSONStringForObject:verification error:&error];
+ [fileManager removeItemAtPath:outputFile error:nil];
+ if (json == nil) {
+ reject(@"EUNSPECIFIED", error.localizedDescription ?: @"could not encode verification result", error);
+ return;
+ }
+ resolve(json);
+ });
+}
+
RCT_EXPORT_METHOD(startPatch:(NSString *)jobId
oldFile:(NSString *)oldFile
newFile:(NSString *)newFile
diff --git a/package.json b/package.json
index b3e02fb..d9748c1 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,8 @@
"test:native-operations": "sh scripts/test-native-operations.sh",
"benchmark:web": "node scripts/benchmark-web.mjs",
"benchmark:native": "node scripts/benchmark-native.mjs",
+ "benchmark:large:web": "BENCHMARK_SIZES_MIB=16,64,128 BENCHMARK_ALLOW_FAILURES=1 node scripts/benchmark-web.mjs",
+ "benchmark:large:native": "BENCHMARK_SIZES_MIB=16,64,128 BENCHMARK_ALLOW_FAILURES=1 node scripts/benchmark-native.mjs",
"site:build": "node scripts/build-site.mjs",
"site:test": "node scripts/test-site.mjs",
"site:test:browser": "node scripts/test-site-browser.mjs",
diff --git a/scripts/benchmark-native.mjs b/scripts/benchmark-native.mjs
index a61a579..50afea0 100644
--- a/scripts/benchmark-native.mjs
+++ b/scripts/benchmark-native.mjs
@@ -62,9 +62,22 @@ try {
executable,
]);
- const results = sizes.map((size) =>
- JSON.parse(run(executable, [String(size)]))
- );
+ const results = sizes.map((sizeMiB) => {
+ try {
+ return {
+ status: 'passed',
+ ...JSON.parse(run(executable, [String(sizeMiB)])),
+ };
+ } catch (error) {
+ return {
+ sizeMiB,
+ status: 'failed',
+ errorCode: 'ENATIVE',
+ errorMessage: error instanceof Error ? error.message : String(error),
+ };
+ }
+ });
+ const failed = results.filter((result) => result.status === 'failed').length;
const report = {
generatedAt: new Date().toISOString(),
runtime: {
@@ -76,6 +89,10 @@ try {
description: 'Deterministic files with one changed byte per 4 KiB',
memoryMetric: 'Peak resident set size for one diff and patch process',
},
+ summary: {
+ passed: results.length - failed,
+ failed,
+ },
results,
};
const serialized = `${JSON.stringify(report, null, 2)}\n`;
@@ -83,6 +100,9 @@ try {
await writeFile(process.env.BENCHMARK_OUTPUT, serialized);
}
process.stdout.write(serialized);
+ if (failed > 0 && process.env.BENCHMARK_ALLOW_FAILURES !== '1') {
+ process.exitCode = 1;
+ }
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
diff --git a/scripts/benchmark-web.mjs b/scripts/benchmark-web.mjs
index d08bdee..1d2230c 100644
--- a/scripts/benchmark-web.mjs
+++ b/scripts/benchmark-web.mjs
@@ -1,3 +1,5 @@
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
import { cpus } from 'node:os';
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
@@ -5,6 +7,7 @@ import { performance } from 'node:perf_hooks';
import { runOperation } from '../web/operations.mjs';
const mebibyte = 1024 * 1024;
+const scriptPath = fileURLToPath(import.meta.url);
const sizes = (process.env.BENCHMARK_SIZES_MIB || '1,10,50')
.split(',')
.map((value) => Number(value.trim()));
@@ -29,52 +32,116 @@ function createInputs(size) {
return { oldData, newData };
}
-const warmup = createInputs(64 * 1024);
-const initializationStartedAt = performance.now();
-await runOperation('diff', warmup.oldData, warmup.newData);
-const initializationMs = performance.now() - initializationStartedAt;
-const results = [];
+function toMiB(bytes) {
+ return Number((bytes / mebibyte).toFixed(1));
+}
-for (const sizeMiB of sizes) {
- const { oldData, newData } = createInputs(sizeMiB * mebibyte);
- const diffStartedAt = performance.now();
- const patchData = await runOperation('diff', oldData, newData);
- const diffMs = performance.now() - diffStartedAt;
- const patchStartedAt = performance.now();
- const restoredData = await runOperation('patch', oldData, patchData);
- const patchMs = performance.now() - patchStartedAt;
+async function runSample(sizeMiB) {
+ try {
+ const warmup = createInputs(64 * 1024);
+ const initializationStartedAt = performance.now();
+ await runOperation('diff', warmup.oldData, warmup.newData);
+ const initializationMs = performance.now() - initializationStartedAt;
+ const { oldData, newData } = createInputs(sizeMiB * mebibyte);
+ const diffStartedAt = performance.now();
+ const patchData = await runOperation('diff', oldData, newData);
+ const diffMs = performance.now() - diffStartedAt;
+ const patchStartedAt = performance.now();
+ const restoredData = await runOperation('patch', oldData, patchData);
+ const patchMs = performance.now() - patchStartedAt;
- if (
- restoredData.byteLength !== newData.byteLength ||
- !restoredData.every((value, index) => value === newData[index])
- ) {
- throw new Error(`Benchmark round trip failed for ${sizeMiB} MiB`);
- }
+ if (
+ restoredData.byteLength !== newData.byteLength ||
+ !restoredData.every((value, index) => value === newData[index])
+ ) {
+ throw new Error(`Benchmark round trip failed for ${sizeMiB} MiB`);
+ }
- results.push({
- sizeMiB,
- diffMs: Number(diffMs.toFixed(1)),
- patchMs: Number(patchMs.toFixed(1)),
- patchBytes: patchData.byteLength,
- });
+ const memory = process.memoryUsage();
+ return {
+ sizeMiB,
+ status: 'passed',
+ initializationMs: Number(initializationMs.toFixed(1)),
+ diffMs: Number(diffMs.toFixed(1)),
+ patchMs: Number(patchMs.toFixed(1)),
+ patchBytes: patchData.byteLength,
+ peakRssMiB: toMiB(process.resourceUsage().maxRSS * 1024),
+ residentAfterMiB: toMiB(memory.rss),
+ externalAfterMiB: toMiB(memory.external),
+ arrayBuffersAfterMiB: toMiB(memory.arrayBuffers),
+ };
+ } catch (error) {
+ return {
+ sizeMiB,
+ status: 'failed',
+ errorCode: error?.code || 'EBENCHMARK',
+ errorMessage: error instanceof Error ? error.message : String(error),
+ peakRssMiB: toMiB(process.resourceUsage().maxRSS * 1024),
+ };
+ }
}
-const report = {
- generatedAt: new Date().toISOString(),
- runtime: {
- cpu: cpus()[0]?.model || 'unknown',
- node: process.version,
- platform: `${process.platform}-${process.arch}`,
- },
- workload: {
- description: 'Deterministic buffers with one changed byte per 4 KiB',
- initializationMs: Number(initializationMs.toFixed(1)),
- },
- results,
-};
-const serializedReport = `${JSON.stringify(report, null, 2)}\n`;
+async function main() {
+ if (process.argv[2] === '--sample') {
+ const sizeMiB = Number(process.argv[3]);
+ if (!Number.isSafeInteger(sizeMiB) || sizeMiB <= 0 || sizeMiB > 512) {
+ throw new Error('Sample size must be an integer from 1 to 512 MiB');
+ }
+ process.stdout.write(`${JSON.stringify(await runSample(sizeMiB))}\n`);
+ return;
+ }
+
+ const results = sizes.map((sizeMiB) => {
+ const sample = spawnSync(
+ process.execPath,
+ [scriptPath, '--sample', String(sizeMiB)],
+ {
+ encoding: 'utf8',
+ env: { ...process.env, BENCHMARK_OUTPUT: '' },
+ maxBuffer: 10 * mebibyte,
+ }
+ );
+ if (sample.status !== 0 || !sample.stdout.trim()) {
+ return {
+ sizeMiB,
+ status: 'failed',
+ errorCode: 'EPROCESS',
+ errorMessage: (sample.stderr || sample.stdout || 'process failed')
+ .trim()
+ .slice(0, 2000),
+ };
+ }
+ return JSON.parse(sample.stdout);
+ });
+ const failed = results.filter((result) => result.status === 'failed').length;
+ const report = {
+ generatedAt: new Date().toISOString(),
+ runtime: {
+ cpu: cpus()[0]?.model || 'unknown',
+ node: process.version,
+ platform: `${process.platform}-${process.arch}`,
+ },
+ workload: {
+ description: 'Deterministic buffers with one changed byte per 4 KiB',
+ memoryMetric:
+ 'Peak resident set size for one initialized WebAssembly diff and patch process',
+ processIsolation: 'Each input size runs in a fresh Node.js process',
+ },
+ summary: {
+ passed: results.length - failed,
+ failed,
+ },
+ results,
+ };
+ const serializedReport = `${JSON.stringify(report, null, 2)}\n`;
-if (process.env.BENCHMARK_OUTPUT) {
- await writeFile(process.env.BENCHMARK_OUTPUT, serializedReport);
+ if (process.env.BENCHMARK_OUTPUT) {
+ await writeFile(process.env.BENCHMARK_OUTPUT, serializedReport);
+ }
+ process.stdout.write(serializedReport);
+ if (failed > 0 && process.env.BENCHMARK_ALLOW_FAILURES !== '1') {
+ process.exitCode = 1;
+ }
}
-process.stdout.write(serializedReport);
+
+await main();
diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
index 3158ff1..40571d6 100644
--- a/scripts/build-site.mjs
+++ b/scripts/build-site.mjs
@@ -51,6 +51,13 @@ const pages = [
'Native resource limits, cancellation, progress, and atomic output behavior.',
file: 'native-operations-v03.md',
},
+ {
+ slug: 'large-files-v04',
+ title: 'Large-file roadmap',
+ description:
+ 'Memory baselines, honest progress semantics, and streaming feasibility for larger inputs.',
+ file: 'large-files-v04.md',
+ },
{
slug: 'troubleshooting',
title: 'Troubleshooting',
@@ -104,6 +111,12 @@ const chinesePages = [
description: '原生资源限制、取消、进度与原子输出行为。',
file: 'native-operations-v03.md',
},
+ {
+ slug: 'large-files-v04',
+ title: '大文件演进路线',
+ description: '面向更大输入的内存基线、真实进度语义与流式处理可行性。',
+ file: 'large-files-v04.md',
+ },
{
slug: 'troubleshooting',
title: '常见问题与排障',
@@ -670,8 +683,8 @@ function localizeHomepage(source) {
['New Architecture', '新架构'],
['Current matrix anchor', '当前矩阵基准'],
['npm tarball', 'npm 安装包'],
- ['125 KiB packed', '125 KiB 压缩包'],
- ['459 KiB unpacked · 58 files', '459 KiB 解压后 · 58 个文件'],
+ ['133 KiB packed', '133 KiB 压缩包'],
+ ['500 KiB unpacked · 58 files', '500 KiB 解压后 · 58 个文件'],
['WebAssembly reference', 'WebAssembly 参考数据'],
['Repeatable, not theoretical.', '可复现,而非纸面数据。'],
[
diff --git a/scripts/test-package-consumers.mjs b/scripts/test-package-consumers.mjs
index e993855..cc3c6bb 100644
--- a/scripts/test-package-consumers.mjs
+++ b/scripts/test-package-consumers.mjs
@@ -232,8 +232,9 @@ try {
await writeFile(
path.join(consumerDirectory, 'browser.mjs'),
[
- "import { diffBytes, patch } from 'react-native-bs-diff-patch';",
+ "import { diffBytes, inspectPatch, patch } from 'react-native-bs-diff-patch';",
"if (typeof diffBytes !== 'function') throw new Error('Missing Web binary API');",
+ "if (typeof inspectPatch !== 'function') throw new Error('Missing patch metadata API');",
"const error = await patch('old', 'new', 'patch').catch((value) => value);",
"if (!error || error.code !== 'EUNSUPPORTED') throw new Error('Wrong Web path API');",
].join('\n')
@@ -245,9 +246,11 @@ try {
await writeFile(
path.join(consumerDirectory, 'consumer.ts'),
[
- "import { diffBytes, type BinaryInput } from 'react-native-bs-diff-patch';",
+ "import { diffBytes, inspectPatch, verifyPatch, type BinaryInput, type PatchMetadata } from 'react-native-bs-diff-patch';",
'const input: BinaryInput = new Uint8Array([1, 2, 3]);',
'void diffBytes(input, input);',
+ 'void inspectPatch(input).then((value: PatchMetadata) => value.valid);',
+ 'void verifyPatch(input, input, input).then((value) => value.verified);',
].join('\n')
);
run(
diff --git a/scripts/test-site-browser.mjs b/scripts/test-site-browser.mjs
index ab4ac06..15004b7 100644
--- a/scripts/test-site-browser.mjs
+++ b/scripts/test-site-browser.mjs
@@ -220,6 +220,57 @@ try {
'ENDSLEY/BSDIFF43'
);
+ await selectFile('#manifest-old-file', fixtures.oldBytes, 'release-v1.bin');
+ await selectFile('#manifest-new-file', fixtures.newBytes, 'release-v2.bin');
+ await selectFile('#manifest-patch-file', fixtures.patch, 'release-v2.patch');
+ await page.click('#manifest-run');
+ await page.waitForSelector('#manifest-status[data-state="success"]');
+ const generatedManifest = JSON.parse(
+ await page.$eval('#manifest-output', (element) => element.textContent || '')
+ );
+ assert.equal(generatedManifest.manifestVersion, 1);
+ assert.equal(generatedManifest.patchFormat, 'ENDSLEY/BSDIFF43');
+ assert.equal(generatedManifest.target.bytes, fixtures.newBytes.length);
+ assert.equal(generatedManifest.target.sha256.length, 64);
+ assert.equal(generatedManifest.patch.sha256.length, 64);
+
+ await page.$eval('#savings-target-mib', (element) => {
+ element.value = '10';
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await page.$eval('#savings-patch-mib', (element) => {
+ element.value = '2';
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await page.$eval('#savings-downloads', (element) => {
+ element.value = '5';
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ assert.equal(
+ await page.$eval('#savings-rate', (element) => element.textContent),
+ '80.0%'
+ );
+ assert.equal(
+ await page.$eval('#savings-equivalent', (element) => element.textContent),
+ '4'
+ );
+
+ await page.select('#diagnostic-code', 'ERESOURCE');
+ assert.match(
+ await page.$eval(
+ '#diagnostic-action',
+ (element) => element.textContent || ''
+ ),
+ /maxInputBytes/
+ );
+ assert.match(
+ await page.$eval(
+ '#diagnostic-example',
+ (element) => element.textContent || ''
+ ),
+ /maxOutputBytes/
+ );
+
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 });
await page.reload({ waitUntil: 'networkidle0' });
const toolsMobile = await page.evaluate(() => ({
diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs
index d7fcf6b..6364ef8 100644
--- a/scripts/test-site.mjs
+++ b/scripts/test-site.mjs
@@ -37,6 +37,8 @@ const requiredFiles = [
'docs/recipes/index.html',
'docs/platform-support/index.html',
'docs/architecture/index.html',
+ 'docs/native-operations-v03/index.html',
+ 'docs/large-files-v04/index.html',
'docs/troubleshooting/index.html',
'docs/development/index.html',
'docs/zh-CN/index.html',
@@ -45,6 +47,8 @@ const requiredFiles = [
'docs/zh-CN/recipes/index.html',
'docs/zh-CN/platform-support/index.html',
'docs/zh-CN/architecture/index.html',
+ 'docs/zh-CN/native-operations-v03/index.html',
+ 'docs/zh-CN/large-files-v04/index.html',
'docs/zh-CN/troubleshooting/index.html',
'docs/zh-CN/development/index.html',
];
@@ -200,8 +204,8 @@ assert.match(homepage, /EINPUT_TOO_LARGE/);
assert.match(homepage, /id="evidence"/);
assert.match(homepage, /RN 0\.73\.11/);
assert.match(homepage, /RN 0\.86\.0/);
-assert.match(homepage, /125 KiB packed/);
-assert.match(homepage, /459 KiB unpacked · 58 files/);
+assert.match(homepage, /133 KiB packed/);
+assert.match(homepage, /500 KiB unpacked · 58 files/);
assert.match(homepage, /30,697\.5 ms/);
assert.match(homepage, /assets\/playground\.js/);
@@ -232,6 +236,9 @@ assert.match(
assert.match(toolsPage, /id="create-panel"/);
assert.match(toolsPage, /id="apply-panel"/);
assert.match(toolsPage, /id="inspect-panel"/);
+assert.match(toolsPage, /id="manifest-run"/);
+assert.match(toolsPage, /id="savings-target-mib"/);
+assert.match(toolsPage, /id="diagnostic-code"/);
assert.match(toolsPage, /id="apply-expected-sha256"/);
assert.match(toolsPage, /id="tool-code"/);
assert.match(toolsPage, /assets\/tools\.js/);
@@ -247,6 +254,9 @@ assert.match(
/ 24);
console.log('Browser Web Worker diff/patch round trip passed');
diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs
index 9def537..7d967cf 100644
--- a/scripts/test-web.mjs
+++ b/scripts/test-web.mjs
@@ -4,6 +4,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runOperation } from '../web/operations.mjs';
+import { inspectPatch } from '../web/index.mjs';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const fixture = JSON.parse(
@@ -33,6 +34,24 @@ assert.deepEqual(
newData,
'patch should reconstruct the new bytes'
);
+const metadata = await inspectPatch(patchData);
+assert.deepEqual(metadata, {
+ declaredTargetBytes: String(newData.byteLength),
+ format: 'ENDSLEY/BSDIFF43',
+ headerBytes: 24,
+ patchBytes: patchData.byteLength,
+ payloadBytes: patchData.byteLength - 24,
+ valid: true,
+});
+assert.deepEqual(await inspectPatch(new Uint8Array([1, 2, 3])), {
+ declaredTargetBytes: null,
+ format: 'UNKNOWN',
+ headerBytes: 3,
+ issue: 'TRUNCATED_HEADER',
+ patchBytes: 3,
+ payloadBytes: 0,
+ valid: false,
+});
const goldenOldData = new Uint8Array(Buffer.from(fixture.oldBase64, 'base64'));
const goldenNewData = new Uint8Array(Buffer.from(fixture.newBase64, 'base64'));
diff --git a/scripts/web-test.html b/scripts/web-test.html
index e07ba88..e7c0bad 100644
--- a/scripts/web-test.html
+++ b/scripts/web-test.html
@@ -11,7 +11,13 @@ BsDiffPatch Web Test
Running…