Skip to content

Commit 76705f9

Browse files
sunnylqmclaude
andcommitted
feat: prefer single-format streaming diff for large bundles
With node-hdiffpatch >= 2.0 (HDiffPatch v5), bundleStreamThreshold routes large bundles through diffSingleStream: generation memory stays O(match block) while the output remains the standard single format that every deployed client already applies — so the streaming path is now safe for the baseline track too, not just the capability-gated v2 track. When only 1.x stream APIs are available the HDIFF13 fallback (v2-track-only) is kept. node-hdiffpatch added as a devDependency so tests exercise the real single-stream path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7ef1247 commit 76705f9

6 files changed

Lines changed: 388 additions & 2 deletions

File tree

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"@types/semver": "^7.7.1",
103103
"@types/yauzl": "^2.10.3",
104104
"@types/yazl": "^2.4.6",
105+
"node-hdiffpatch": "^2.0.3",
105106
"typescript": "^6.0.2"
106107
},
107108
"trustedDependencies": [

src/diff.ts

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from 'fs-extra';
22
import { npm, yarn } from 'global-dirs';
3+
import os from 'os';
34
import path from 'path';
45
import type { Entry, ZipFile as YauzlZipFile } from 'yauzl';
56
import { ZipFile as YazlZipFile } from 'yazl';
@@ -29,9 +30,23 @@ type Diff = (
2930
newSource?: Buffer,
3031
) => Buffer | Promise<Buffer>;
3132
type Patch = (oldSource: Buffer, diffData: Buffer) => Buffer;
33+
type DiffStream = (
34+
oldPath: string,
35+
newPath: string,
36+
outDiffPath: string,
37+
) => unknown | Promise<unknown>;
38+
type PatchStream = (
39+
oldPath: string,
40+
diffPath: string,
41+
outNewPath: string,
42+
) => unknown | Promise<unknown>;
3243
type HdiffModule = {
3344
diff?: Diff;
3445
patch?: Patch;
46+
diffStream?: DiffStream;
47+
patchStream?: PatchStream;
48+
diffSingleStream?: DiffStream;
49+
patchSingleStream?: PatchStream;
3550
};
3651
type EntryMap = Record<string, { crc32: number; fileName: string }>;
3752
type CrcMap = Record<number, string>;
@@ -93,8 +108,72 @@ type BundleDiffOptions = {
93108
patchFn?: Patch;
94109
/** 测试用:覆写跳过变换尝试的 baseline 大小下限 */
95110
minBaselineBytes?: number;
111+
/**
112+
* bundle 尺寸达到该字节数时改走流式 diff:生成端内存 O(块匹配),
113+
* 彻底绕开内存 diff 的后缀数组峰值(~6× bundle)。0/未设 = 关闭。
114+
* 优先使用 single 格式流式生成(diffSingleStream,产物与 diff() 同格式,
115+
* 老客户端可直接应用,任何轨道均可开启);仅当 single 流式不可用时回退
116+
* HDIFF13(diffStream)——该格式老客户端不识别,只应在 v2 轨道下开启。
117+
*/
118+
streamThresholdBytes?: number;
119+
diffStreamFn?: DiffStream;
120+
patchStreamFn?: PatchStream;
121+
/** 流式产物是否为 single 格式(决定 baseline 轨道是否可用) */
122+
streamEmitsSingleFormat?: boolean;
96123
};
97124

125+
/**
126+
* 大 bundle 流式路径:可选 HBC 变换(在 buffer 上做,写临时文件)后
127+
* diffStream 生成 HDIFF13,并以 patchStream + T⁻¹ 做 round-trip 自检。
128+
* 自检失败直接抛错(此路径没有 baseline 可回退——内存 diff 正是要避免的),
129+
* 由上层按任务失败重试/告警处理。
130+
*/
131+
async function buildStreamBundlePatch(
132+
originSource: Buffer,
133+
newSource: Buffer,
134+
options: BundleDiffOptions,
135+
): Promise<{ patchData: Buffer; hbcTransform?: HbcTransformMeta }> {
136+
const diffStreamFn = options.diffStreamFn;
137+
const patchStreamFn = options.patchStreamFn;
138+
if (!diffStreamFn || !patchStreamFn) {
139+
throw new Error(
140+
'stream diff requires diffStream/patchStream from node-hdiffpatch',
141+
);
142+
}
143+
144+
const pair = options.hbcTransform
145+
? tryTransformPair(originSource, newSource)
146+
: null;
147+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-stream-diff-'));
148+
try {
149+
const oldFile = path.join(tempRoot, 'old.bin');
150+
const newFile = path.join(tempRoot, 'new.bin');
151+
const patchFile = path.join(tempRoot, 'patch.bin');
152+
const restoredFile = path.join(tempRoot, 'restored.bin');
153+
fs.writeFileSync(oldFile, pair ? pair.tOld : originSource);
154+
fs.writeFileSync(newFile, pair ? pair.tNew : newSource);
155+
156+
await diffStreamFn(oldFile, newFile, patchFile);
157+
158+
// round-trip 自检:patchStream 还原(变换域则再做 T⁻¹)必须与新 bundle 一致
159+
await patchStreamFn(oldFile, patchFile, restoredFile);
160+
const restoredRaw: Buffer = fs.readFileSync(restoredFile);
161+
const restored = pair
162+
? transformHbcWithLayout(restoredRaw, pair.layout, true)
163+
: restoredRaw;
164+
if (!restored || Buffer.compare(restored, newSource) !== 0) {
165+
throw new Error('stream diff round-trip verification failed');
166+
}
167+
168+
return {
169+
patchData: fs.readFileSync(patchFile),
170+
...(pair ? { hbcTransform: pair.meta } : {}),
171+
};
172+
} finally {
173+
fs.rmSync(tempRoot, { recursive: true, force: true });
174+
}
175+
}
176+
98177
/**
99178
* 生成 bundle patch:默认现状路径;开启 hbcTransform 时对 Hermes bundle
100179
* 做双候选择优(baseline vs 变换域 + 元数据开销),且变换候选必须通过
@@ -106,6 +185,15 @@ async function buildBundlePatch(
106185
newSource: Buffer,
107186
options: BundleDiffOptions,
108187
): Promise<{ patchData: Buffer; hbcTransform?: HbcTransformMeta }> {
188+
if (
189+
options.streamThresholdBytes &&
190+
originSource &&
191+
Math.max(originSource.length, newSource.length) >=
192+
options.streamThresholdBytes
193+
) {
194+
return buildStreamBundlePatch(originSource, newSource, options);
195+
}
196+
109197
const baseline = await diffFn(originSource, newSource);
110198
if (
111199
!options.hbcTransform ||
@@ -422,8 +510,13 @@ async function diffFromPackage(
422510
type DiffCommandOptions = {
423511
customDiff?: Diff;
424512
customPatch?: Patch;
513+
customDiffStream?: DiffStream;
514+
customPatchStream?: PatchStream;
515+
customDiffSingleStream?: DiffStream;
516+
customPatchSingleStream?: PatchStream;
425517
customHdiffModule?: HdiffModule;
426518
hbcTransform?: boolean;
519+
bundleStreamThreshold?: number;
427520
[key: string]: any;
428521
};
429522

@@ -453,17 +546,51 @@ function resolvePatchImplementation(
453546
function resolveBundleDiffOptions(
454547
options: DiffCommandOptions,
455548
): BundleDiffOptions {
549+
const hdiffModule = options.customHdiffModule ?? hdiff;
550+
const streamOptions: Pick<
551+
BundleDiffOptions,
552+
| 'streamThresholdBytes'
553+
| 'diffStreamFn'
554+
| 'patchStreamFn'
555+
| 'streamEmitsSingleFormat'
556+
> = {};
557+
if (
558+
typeof options.bundleStreamThreshold === 'number' &&
559+
options.bundleStreamThreshold > 0
560+
) {
561+
const singleDiffStreamFn =
562+
options.customDiffSingleStream ?? hdiffModule?.diffSingleStream;
563+
const singlePatchStreamFn =
564+
options.customPatchSingleStream ?? hdiffModule?.patchSingleStream;
565+
const diffStreamFn = options.customDiffStream ?? hdiffModule?.diffStream;
566+
const patchStreamFn = options.customPatchStream ?? hdiffModule?.patchStream;
567+
if (singleDiffStreamFn && singlePatchStreamFn) {
568+
streamOptions.streamThresholdBytes = options.bundleStreamThreshold;
569+
streamOptions.diffStreamFn = singleDiffStreamFn;
570+
streamOptions.patchStreamFn = singlePatchStreamFn;
571+
streamOptions.streamEmitsSingleFormat = true;
572+
} else if (diffStreamFn && patchStreamFn) {
573+
streamOptions.streamThresholdBytes = options.bundleStreamThreshold;
574+
streamOptions.diffStreamFn = diffStreamFn;
575+
streamOptions.patchStreamFn = patchStreamFn;
576+
streamOptions.streamEmitsSingleFormat = false;
577+
} else {
578+
console.warn(t('bundleStreamNeedsHdiffpatch'));
579+
}
580+
}
581+
456582
if (!options.hbcTransform) {
457-
return {};
583+
return { ...streamOptions };
458584
}
459585
const patchFn = resolvePatchImplementation(options);
460586
if (!patchFn) {
461587
console.warn(t('hbcTransformNeedsPatch'));
462-
return {};
588+
return { ...streamOptions };
463589
}
464590
return {
465591
hbcTransform: true,
466592
patchFn,
593+
...streamOptions,
467594
...(typeof options.hbcTransformMinBaseline === 'number'
468595
? { minBaselineBytes: options.hbcTransformMinBaseline }
469596
: {}),

src/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks.
187187
'HBC transform round-trip verification failed; falling back to plain diff.',
188188
hbcTransformNeedsPatch:
189189
'hbcTransform requires patch support from node-hdiffpatch; option ignored.',
190+
bundleStreamNeedsHdiffpatch:
191+
'bundleStreamThreshold requires diffStream/patchStream from node-hdiffpatch; option ignored.',
190192
apkExtracted: 'APK extracted to {{output}}',
191193
composeSourceMapsNotFound:
192194
'Cannot find react-native/scripts/compose-source-maps.js, skipping hermes sourcemap composing. The uploaded sourcemap may not match the compiled bundle.',

src/locales/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ export default {
172172
'HBC 变换 round-trip 自检未通过,已回退普通 diff。',
173173
hbcTransformNeedsPatch:
174174
'hbcTransform 需要 node-hdiffpatch 的 patch 能力,选项已忽略。',
175+
bundleStreamNeedsHdiffpatch:
176+
'bundleStreamThreshold 需要 node-hdiffpatch 的 diffStream/patchStream 能力,选项已忽略。',
175177
apkExtracted: 'APK 已提取到 {{output}}',
176178
composeSourceMapsNotFound:
177179
'找不到 react-native/scripts/compose-source-maps.js,跳过 hermes sourcemap 合成。上传的 sourcemap 可能与编译后的 bundle 不匹配。',

0 commit comments

Comments
 (0)