Skip to content

Commit 9b00fea

Browse files
committed
feat: upload slim native packages
1 parent 33b1df2 commit 9b00fea

6 files changed

Lines changed: 614 additions & 39 deletions

File tree

src/diff.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import os from 'os';
44
import path from 'path';
55
import type { Entry, ZipFile as YauzlZipFile } from 'yauzl';
66
import { ZipFile as YazlZipFile } from 'yazl';
7-
import type { CommandContext } from './types';
7+
import { resolveNativePackageEntry } from './native-package';
8+
import type { CommandContext, Platform } from './types';
89
import { translateOptions } from './utils';
910
import { isPPKBundleFileName, scriptName, tempDir } from './utils/constants';
1011
import {
@@ -51,13 +52,11 @@ type HdiffModule = {
5152
type EntryMap = Record<string, { crc32: number; fileName: string }>;
5253
type CrcMap = Record<number, string>;
5354
type CopyMap = Record<string, string>;
54-
type PackagePathTransform = (v: string) => string | undefined;
5555
type DiffTarget =
5656
| { kind: 'ppk' }
5757
| {
5858
kind: 'package';
59-
originBundleName: string;
60-
transformPackagePath?: PackagePathTransform;
59+
platform: Platform;
6160
};
6261
type DiffCommandConfig = {
6362
diffFnName: string;
@@ -419,8 +418,7 @@ async function diffFromPackage(
419418
next: string,
420419
output: string,
421420
diffFn: Diff,
422-
originBundleName: string,
423-
transformPackagePath: (v: string) => string | undefined = (v: string) => v,
421+
platform: Platform,
424422
bundleOptions: BundleDiffOptions = {},
425423
) {
426424
const originEntries: Record<string, number> = {};
@@ -437,17 +435,18 @@ async function diffFromPackage(
437435

438436
await enumZipEntries(origin, async (entry, zipFile) => {
439437
if (!/\/$/.test(entry.fileName)) {
440-
const fn = transformPackagePath(entry.fileName);
441-
if (!fn) {
438+
const resolvedEntry = resolveNativePackageEntry(platform, entry.fileName);
439+
if (!resolvedEntry) {
442440
return;
443441
}
442+
const fn = resolvedEntry.diffPath;
444443

445444
//console.log(fn);
446445
// isFile
447446
originEntries[fn] = entry.crc32;
448447
originMap[entry.crc32] = fn;
449448

450-
if (fn === originBundleName) {
449+
if (resolvedEntry.kind === 'bundle') {
451450
// This is source.
452451
originSource = await readEntry(entry, zipFile);
453452
}
@@ -625,11 +624,6 @@ function diffArgsCheck(
625624
};
626625
}
627626

628-
const transformIpaPackagePath: PackagePathTransform = (v) => {
629-
const match = /^Payload\/[^/]+\/(.+)$/.exec(v);
630-
return match?.[1];
631-
};
632-
633627
const createDiffCommand =
634628
({ diffFnName, target }: DiffCommandConfig) =>
635629
async ({ args, options }: CommandContext) => {
@@ -650,8 +644,7 @@ const createDiffCommand =
650644
next,
651645
realOutput,
652646
diffFn,
653-
target.originBundleName,
654-
target.transformPackagePath,
647+
target.platform,
655648
bundleOptions,
656649
);
657650
}
@@ -668,22 +661,21 @@ export const diffCommands = {
668661
diffFnName: 'hdiffFromApk',
669662
target: {
670663
kind: 'package',
671-
originBundleName: 'assets/index.android.bundle',
664+
platform: 'android',
672665
},
673666
}),
674667
hdiffFromApp: createDiffCommand({
675668
diffFnName: 'hdiffFromApp',
676669
target: {
677670
kind: 'package',
678-
originBundleName: 'resources/rawfile/bundle.harmony.js',
671+
platform: 'harmony',
679672
},
680673
}),
681674
hdiffFromIpa: createDiffCommand({
682675
diffFnName: 'hdiffFromIpa',
683676
target: {
684677
kind: 'package',
685-
originBundleName: 'main.jsbundle',
686-
transformPackagePath: transformIpaPackagePath,
678+
platform: 'ios',
687679
},
688680
}),
689681
};

src/native-package.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import * as fs from 'fs-extra';
2+
import os from 'os';
3+
import path from 'path';
4+
import {
5+
type Entry,
6+
open as openZipFile,
7+
type ZipFile as YauzlZipFile,
8+
} from 'yauzl';
9+
import { ZipFile as YazlZipFile } from 'yazl';
10+
import type { Platform } from './types';
11+
12+
export type NativePackageEntry = {
13+
diffPath: string;
14+
kind: 'bundle' | 'resource';
15+
};
16+
17+
type NativePackageRule = {
18+
resolveEntry: (entryName: string) => NativePackageEntry | undefined;
19+
};
20+
21+
const nativePackageRules: Record<Platform, NativePackageRule> = {
22+
android: {
23+
resolveEntry: (entryName) => {
24+
if (entryName === 'assets/index.android.bundle') {
25+
return { diffPath: entryName, kind: 'bundle' };
26+
}
27+
if (entryName.startsWith('assets/') || entryName.startsWith('res/')) {
28+
return { diffPath: entryName, kind: 'resource' };
29+
}
30+
},
31+
},
32+
ios: {
33+
resolveEntry: (entryName) => {
34+
const match = /^Payload\/[^/]+\.app\/(.+)$/.exec(entryName);
35+
const appPath = match?.[1];
36+
if (appPath === 'main.jsbundle') {
37+
return { diffPath: appPath, kind: 'bundle' };
38+
}
39+
if (appPath?.startsWith('assets/')) {
40+
return { diffPath: appPath, kind: 'resource' };
41+
}
42+
},
43+
},
44+
harmony: {
45+
resolveEntry: (entryName) => {
46+
if (entryName === 'resources/rawfile/bundle.harmony.js') {
47+
return { diffPath: entryName, kind: 'bundle' };
48+
}
49+
if (entryName.startsWith('resources/rawfile/assets/')) {
50+
return { diffPath: entryName, kind: 'resource' };
51+
}
52+
},
53+
},
54+
};
55+
56+
/**
57+
* Resolve an archive entry that can be used as a native-package diff origin.
58+
* Upload extraction and diff indexing deliberately share this function so a
59+
* file cannot be removed from the uploaded baseline while still being
60+
* considered by the local diff implementation (or vice versa).
61+
*/
62+
export function resolveNativePackageEntry(
63+
platform: Platform,
64+
entryName: string,
65+
): NativePackageEntry | undefined {
66+
return nativePackageRules[platform].resolveEntry(entryName);
67+
}
68+
69+
function copyEntryToZip(
70+
sourceZip: YauzlZipFile,
71+
entry: Entry,
72+
outputZip: YazlZipFile,
73+
): Promise<void> {
74+
return new Promise((resolve, reject) => {
75+
sourceZip.openReadStream(entry, (error, readStream) => {
76+
if (error) {
77+
reject(error);
78+
return;
79+
}
80+
if (!readStream) {
81+
reject(new Error(`Unable to read zip entry: ${entry.fileName}`));
82+
return;
83+
}
84+
85+
readStream.once('error', reject);
86+
readStream.once('end', () => resolve());
87+
outputZip.addReadStream(readStream, entry.fileName, {
88+
compress: entry.compressionMethod !== 0,
89+
mtime: entry.getLastModDate(),
90+
});
91+
});
92+
});
93+
}
94+
95+
function extractEntryToFile(
96+
sourceZip: YauzlZipFile,
97+
entry: Entry,
98+
outputPath: string,
99+
): Promise<void> {
100+
return new Promise((resolve, reject) => {
101+
sourceZip.openReadStream(entry, (error, readStream) => {
102+
if (error) {
103+
reject(error);
104+
return;
105+
}
106+
if (!readStream) {
107+
reject(new Error(`Unable to read zip entry: ${entry.fileName}`));
108+
return;
109+
}
110+
111+
const writeStream = fs.createWriteStream(outputPath);
112+
readStream.once('error', reject);
113+
writeStream.once('error', reject);
114+
writeStream.once('close', () => resolve());
115+
readStream.pipe(writeStream);
116+
});
117+
});
118+
}
119+
120+
async function filterNativePackageArchive(
121+
source: string,
122+
output: string,
123+
platform: Platform,
124+
): Promise<{ bundles: number; entries: number }> {
125+
await fs.ensureDir(path.dirname(output));
126+
const scratchDir = await fs.mkdtemp(
127+
path.join(os.tmpdir(), 'rnu-native-package-entry-'),
128+
);
129+
const outputZip = new YazlZipFile();
130+
const writePromise = new Promise<void>((resolve, reject) => {
131+
outputZip.outputStream.once('error', reject);
132+
outputZip.outputStream
133+
.pipe(fs.createWriteStream(output))
134+
.once('error', reject)
135+
.once('close', () => resolve());
136+
});
137+
let includedEntries = 0;
138+
let includedBundles = 0;
139+
let nestedArchiveIndex = 0;
140+
141+
try {
142+
await new Promise<void>((resolve, reject) => {
143+
openZipFile(source, { lazyEntries: true }, (openError, sourceZip) => {
144+
if (openError || !sourceZip) {
145+
reject(openError ?? new Error(`Unable to open zip file: ${source}`));
146+
return;
147+
}
148+
149+
let settled = false;
150+
const fail = (error: unknown) => {
151+
if (settled) {
152+
return;
153+
}
154+
settled = true;
155+
sourceZip.close();
156+
reject(error);
157+
};
158+
159+
sourceZip.once('error', fail);
160+
sourceZip.once('end', () => {
161+
if (!settled) {
162+
settled = true;
163+
resolve();
164+
}
165+
});
166+
sourceZip.on('entry', async (entry) => {
167+
try {
168+
if (entry.fileName.endsWith('/')) {
169+
sourceZip.readEntry();
170+
return;
171+
}
172+
173+
// Harmony .app packages contain one or more nested .hap archives.
174+
// Rebuild those archives recursively and keep their outer path so
175+
// the slim package has the same nesting as the original package.
176+
if (
177+
platform === 'harmony' &&
178+
entry.fileName.toLowerCase().endsWith('.hap')
179+
) {
180+
const index = nestedArchiveIndex++;
181+
const nestedSource = path.join(scratchDir, `${index}.source.hap`);
182+
const nestedOutput = path.join(scratchDir, `${index}.slim.hap`);
183+
await extractEntryToFile(sourceZip, entry, nestedSource);
184+
const nestedResult = await filterNativePackageArchive(
185+
nestedSource,
186+
nestedOutput,
187+
platform,
188+
);
189+
if (nestedResult.entries > 0) {
190+
outputZip.addFile(nestedOutput, entry.fileName, {
191+
compress: entry.compressionMethod !== 0,
192+
mtime: entry.getLastModDate(),
193+
});
194+
includedEntries += nestedResult.entries;
195+
includedBundles += nestedResult.bundles;
196+
}
197+
sourceZip.readEntry();
198+
return;
199+
}
200+
201+
const resolvedEntry = resolveNativePackageEntry(
202+
platform,
203+
entry.fileName,
204+
);
205+
if (resolvedEntry) {
206+
await copyEntryToZip(sourceZip, entry, outputZip);
207+
includedEntries += 1;
208+
if (resolvedEntry.kind === 'bundle') {
209+
includedBundles += 1;
210+
}
211+
}
212+
sourceZip.readEntry();
213+
} catch (error) {
214+
fail(error);
215+
}
216+
});
217+
218+
sourceZip.readEntry();
219+
});
220+
});
221+
222+
outputZip.end();
223+
await writePromise;
224+
return { bundles: includedBundles, entries: includedEntries };
225+
} catch (error) {
226+
outputZip.end();
227+
await writePromise.catch(() => {});
228+
throw error;
229+
} finally {
230+
await fs.remove(scratchDir);
231+
}
232+
}
233+
234+
/**
235+
* Repack a native package with only the bundle and resources required by
236+
* package-origin diffs. Entry names and nested Harmony HAP structure are kept.
237+
*/
238+
export async function createSlimNativePackage(
239+
source: string,
240+
output: string,
241+
platform: Platform,
242+
): Promise<void> {
243+
const result = await filterNativePackageArchive(source, output, platform);
244+
if (result.bundles === 0) {
245+
await fs.remove(output);
246+
throw new Error(`Bundle entry not found in native package: ${source}`);
247+
}
248+
}

0 commit comments

Comments
 (0)