Skip to content

Commit 7ef1247

Browse files
sunnylqmclaude
andcommitted
feat: HBC delta-friendly transform for Hermes bundle diffs (hbcTransform)
Adds src/utils/hbcTransform.ts: a data-driven, version-branch-free interpreter that forward-differences the absolute-offset tables in Hermes bytecode (functionHeaders, smallStringTable, overflowStringTable, etc.), so whole-table shifts collapse to single-point changes in the diff domain. Layout descriptors cover HBC v87-96 and both v98 header variants (Static Hermes inserted numStringSwitchImms without bumping the version; variants are resolved structurally). The descriptor used is embedded in __diff.json as wire-format metadata, so clients interpret rather than track Hermes versions. hdiff gains an opt-in --hbcTransform flag (default off until client SDK support ships): dual-candidate pick-min (baseline vs transformed + metadata overhead), 64KB baseline pruning, and a local T(old)->hpatch->T-inverse round-trip self-check — the transformed patch is only emitted when strictly smaller and provably restorable. Production replay on real update pairs (v89/94/96/98): 35/35 transformed, zero invertibility failures, median 21.9% patch-size reduction in the ppk-to-ppk fan-out segment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 62ba0a2 commit 7ef1247

9 files changed

Lines changed: 1022 additions & 9 deletions

File tree

cli.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@
319319
"output": {
320320
"default": "${tempDir}/output/hdiff",
321321
"hasValue": true
322+
},
323+
"hbcTransform": {
324+
"default": false
322325
}
323326
}
324327
},

src/diff.ts

Lines changed: 150 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { ZipFile as YazlZipFile } from 'yazl';
66
import type { CommandContext } from './types';
77
import { translateOptions } from './utils';
88
import { isPPKBundleFileName, scriptName, tempDir } from './utils/constants';
9+
import {
10+
type HbcTransformMeta,
11+
transformHbcWithLayout,
12+
tryTransformPair,
13+
} from './utils/hbcTransform';
914
import { t } from './utils/i18n';
1015
import {
1116
enumZipEntries,
@@ -23,8 +28,10 @@ type Diff = (
2328
oldSource?: Buffer,
2429
newSource?: Buffer,
2530
) => Buffer | Promise<Buffer>;
31+
type Patch = (oldSource: Buffer, diffData: Buffer) => Buffer;
2632
type HdiffModule = {
2733
diff?: Diff;
34+
patch?: Patch;
2835
};
2936
type EntryMap = Record<string, { crc32: number; fileName: string }>;
3037
type CrcMap = Record<number, string>;
@@ -77,19 +84,84 @@ function createOutputZip(output: string) {
7784
return { zipfile, writePromise };
7885
}
7986

87+
// baseline patch 小于该值时跳过变换尝试:节省一次 diff 的 CPU,
88+
// 且此时变换收益上限(~25%)也抵不过元数据开销
89+
const HBC_TRANSFORM_MIN_BASELINE_BYTES = 64 * 1024;
90+
91+
type BundleDiffOptions = {
92+
hbcTransform?: boolean;
93+
patchFn?: Patch;
94+
/** 测试用:覆写跳过变换尝试的 baseline 大小下限 */
95+
minBaselineBytes?: number;
96+
};
97+
98+
/**
99+
* 生成 bundle patch:默认现状路径;开启 hbcTransform 时对 Hermes bundle
100+
* 做双候选择优(baseline vs 变换域 + 元数据开销),且变换候选必须通过
101+
* 本地 round-trip 还原自检才会被采用——收益永不为负,未知格式天然安全。
102+
*/
103+
async function buildBundlePatch(
104+
diffFn: Diff,
105+
originSource: Buffer | undefined,
106+
newSource: Buffer,
107+
options: BundleDiffOptions,
108+
): Promise<{ patchData: Buffer; hbcTransform?: HbcTransformMeta }> {
109+
const baseline = await diffFn(originSource, newSource);
110+
if (
111+
!options.hbcTransform ||
112+
!options.patchFn ||
113+
!originSource ||
114+
baseline.length <
115+
(options.minBaselineBytes ?? HBC_TRANSFORM_MIN_BASELINE_BYTES)
116+
) {
117+
return { patchData: baseline };
118+
}
119+
120+
const pair = tryTransformPair(originSource, newSource);
121+
if (!pair) {
122+
return { patchData: baseline };
123+
}
124+
125+
try {
126+
const candidate = await diffFn(pair.tOld, pair.tNew);
127+
const metaOverhead = Buffer.byteLength(JSON.stringify(pair.meta));
128+
if (candidate.length + metaOverhead >= baseline.length) {
129+
return { patchData: baseline };
130+
}
131+
132+
const patched = options.patchFn(pair.tOld, candidate);
133+
const restored = transformHbcWithLayout(patched, pair.layout, true);
134+
if (!restored || Buffer.compare(restored, newSource) !== 0) {
135+
console.warn(t('hbcTransformRoundTripFailed'));
136+
return { patchData: baseline };
137+
}
138+
return { patchData: candidate, hbcTransform: pair.meta };
139+
} catch {
140+
return { patchData: baseline };
141+
}
142+
}
143+
80144
async function addBundlePatch(
81145
zipfile: YazlZipFile,
82146
entry: Entry,
83147
nextZipfile: YauzlZipFile,
84148
diffFn: Diff,
85149
originSource: Buffer | undefined,
150+
bundleOptions: BundleDiffOptions,
151+
hbcTransformMetas: Record<string, HbcTransformMeta>,
86152
) {
87153
const newSource = await readEntry(entry, nextZipfile);
88-
zipfile.addBuffer(
89-
await diffFn(originSource, newSource),
90-
`${entry.fileName}.patch`,
91-
zipOptionsForPatchEntry(),
154+
const { patchData, hbcTransform } = await buildBundlePatch(
155+
diffFn,
156+
originSource,
157+
newSource,
158+
bundleOptions,
92159
);
160+
const patchEntryName = `${entry.fileName}.patch`;
161+
if (hbcTransform) {
162+
hbcTransformMetas[patchEntryName] = hbcTransform;
163+
}
164+
zipfile.addBuffer(patchData, patchEntryName, zipOptionsForPatchEntry());
93165
}
94166

95167
async function addFileFromZipEntry(
@@ -143,6 +215,7 @@ async function diffFromPPK(
143215
next: string,
144216
output: string,
145217
diffFn: Diff,
218+
bundleOptions: BundleDiffOptions = {},
146219
) {
147220
const originEntries: EntryMap = {};
148221
const originMap: CrcMap = {};
@@ -186,6 +259,7 @@ async function diffFromPPK(
186259
}
187260

188261
const newEntries: EntryMap = {};
262+
const hbcTransformMetas: Record<string, HbcTransformMeta> = {};
189263

190264
await enumZipEntries(next, async (entry, nextZipfile) => {
191265
newEntries[entry.fileName] = entry;
@@ -196,7 +270,15 @@ async function diffFromPPK(
196270
addEntry(entry.fileName);
197271
}
198272
} else if (isPPKBundleFileName(entry.fileName)) {
199-
await addBundlePatch(zipfile, entry, nextZipfile, diffFn, originSource);
273+
await addBundlePatch(
274+
zipfile,
275+
entry,
276+
nextZipfile,
277+
diffFn,
278+
originSource,
279+
bundleOptions,
280+
hbcTransformMetas,
281+
);
200282
} else {
201283
// If same file.
202284
const originEntry = originEntries[entry.fileName];
@@ -235,7 +317,13 @@ async function diffFromPPK(
235317
}
236318
}
237319

238-
await finishDiffZip(zipfile, writePromise, { copies, deletes });
320+
await finishDiffZip(zipfile, writePromise, {
321+
copies,
322+
deletes,
323+
...(Object.keys(hbcTransformMetas).length
324+
? { hbcTransform: hbcTransformMetas }
325+
: {}),
326+
});
239327
}
240328

241329
async function diffFromPackage(
@@ -245,6 +333,7 @@ async function diffFromPackage(
245333
diffFn: Diff,
246334
originBundleName: string,
247335
transformPackagePath: (v: string) => string | undefined = (v: string) => v,
336+
bundleOptions: BundleDiffOptions = {},
248337
) {
249338
const originEntries: Record<string, number> = {};
250339
const originMap: CrcMap = {};
@@ -284,13 +373,22 @@ async function diffFromPackage(
284373
const copies: CopyMap = {};
285374

286375
const { zipfile, writePromise } = createOutputZip(output);
376+
const hbcTransformMetas: Record<string, HbcTransformMeta> = {};
287377

288378
await enumZipEntries(next, async (entry, nextZipfile) => {
289379
if (/\/$/.test(entry.fileName)) {
290380
// Directory
291381
zipfile.addEmptyDirectory(entry.fileName);
292382
} else if (isPPKBundleFileName(entry.fileName)) {
293-
await addBundlePatch(zipfile, entry, nextZipfile, diffFn, originSource);
383+
await addBundlePatch(
384+
zipfile,
385+
entry,
386+
nextZipfile,
387+
diffFn,
388+
originSource,
389+
bundleOptions,
390+
hbcTransformMetas,
391+
);
294392
} else {
295393
// If same file.
296394
if (originEntries[entry.fileName] === entry.crc32) {
@@ -312,12 +410,20 @@ async function diffFromPackage(
312410
}
313411
});
314412

315-
await finishDiffZip(zipfile, writePromise, { copies, copiesCrc });
413+
await finishDiffZip(zipfile, writePromise, {
414+
copies,
415+
copiesCrc,
416+
...(Object.keys(hbcTransformMetas).length
417+
? { hbcTransform: hbcTransformMetas }
418+
: {}),
419+
});
316420
}
317421

318422
type DiffCommandOptions = {
319423
customDiff?: Diff;
424+
customPatch?: Patch;
320425
customHdiffModule?: HdiffModule;
426+
hbcTransform?: boolean;
321427
[key: string]: any;
322428
};
323429

@@ -333,6 +439,37 @@ function resolveDiffImplementation(options: DiffCommandOptions): Diff {
333439
return hdiffModule.diff;
334440
}
335441

442+
// round-trip 自检需要 patch 能力;拿不到时禁用变换(安全优先),不报错
443+
function resolvePatchImplementation(
444+
options: DiffCommandOptions,
445+
): Patch | undefined {
446+
if (options.customPatch) {
447+
return options.customPatch;
448+
}
449+
const hdiffModule = options.customHdiffModule ?? hdiff;
450+
return hdiffModule?.patch;
451+
}
452+
453+
function resolveBundleDiffOptions(
454+
options: DiffCommandOptions,
455+
): BundleDiffOptions {
456+
if (!options.hbcTransform) {
457+
return {};
458+
}
459+
const patchFn = resolvePatchImplementation(options);
460+
if (!patchFn) {
461+
console.warn(t('hbcTransformNeedsPatch'));
462+
return {};
463+
}
464+
return {
465+
hbcTransform: true,
466+
patchFn,
467+
...(typeof options.hbcTransformMinBaseline === 'number'
468+
? { minBaselineBytes: options.hbcTransformMinBaseline }
469+
: {}),
470+
};
471+
}
472+
336473
function diffArgsCheck(
337474
args: string[],
338475
options: DiffCommandOptions,
@@ -374,9 +511,12 @@ const createDiffCommand =
374511
options as DiffCommandOptions,
375512
diffFnName,
376513
);
514+
const bundleOptions = resolveBundleDiffOptions(
515+
options as DiffCommandOptions,
516+
);
377517

378518
if (target.kind === 'ppk') {
379-
await diffFromPPK(origin, next, realOutput, diffFn);
519+
await diffFromPPK(origin, next, realOutput, diffFn, bundleOptions);
380520
} else {
381521
await diffFromPackage(
382522
origin,
@@ -385,6 +525,7 @@ const createDiffCommand =
385525
diffFn,
386526
target.originBundleName,
387527
target.transformPackagePath,
528+
bundleOptions,
388529
);
389530
}
390531

src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ This can reduce the risk of inconsistent dependencies and supply chain attacks.
183183
diffPackageGenerated: '{{- output}} generated.',
184184
nodeHdiffpatchRequired:
185185
'This function needs "node-hdiffpatch". Please run "{{scriptName}} install node-hdiffpatch" to install',
186+
hbcTransformRoundTripFailed:
187+
'HBC transform round-trip verification failed; falling back to plain diff.',
188+
hbcTransformNeedsPatch:
189+
'hbcTransform requires patch support from node-hdiffpatch; option ignored.',
186190
apkExtracted: 'APK extracted to {{output}}',
187191
composeSourceMapsNotFound:
188192
'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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ export default {
168168
diffPackageGenerated: '{{- output}} 已生成。',
169169
nodeHdiffpatchRequired:
170170
'此功能需要 "node-hdiffpatch"。请运行 "{{scriptName}} install node-hdiffpatch" 来安装',
171+
hbcTransformRoundTripFailed:
172+
'HBC 变换 round-trip 自检未通过,已回退普通 diff。',
173+
hbcTransformNeedsPatch:
174+
'hbcTransform 需要 node-hdiffpatch 的 patch 能力,选项已忽略。',
171175
apkExtracted: 'APK 已提取到 {{output}}',
172176
composeSourceMapsNotFound:
173177
'找不到 react-native/scripts/compose-source-maps.js,跳过 hermes sourcemap 合成。上传的 sourcemap 可能与编译后的 bundle 不匹配。',

0 commit comments

Comments
 (0)