Skip to content

Commit 14dbfc8

Browse files
committed
feat(metadata): add ns metadata trim to keep only the native APIs an app uses
Analyzes an app's emitted JavaScript with the runtime's own tools and generates metadata for what it references plus the types those depend on. On Apple platforms the result can be embedded into a copy of NativeScript.framework, shrinking the binary. On Android it writes the whitelist Gradle consumes with -PnsFilterMetadata.
1 parent c2ca309 commit 14dbfc8

10 files changed

Lines changed: 1242 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<% if (isJekyll) { %>---
2+
title: ns metadata trim
3+
position: 26
4+
---<% } %>
5+
6+
# ns metadata trim
7+
8+
### Description
9+
10+
Reduces the native API metadata an app ships to what its JavaScript uses. The app's emitted bundle is analyzed for the native symbols it references, and the generator keeps those plus the types they depend on.
11+
12+
If the bundle reads globals dynamically (an alias of `globalThis`, for example), its usage cannot be determined and nothing is trimmed.
13+
14+
### Commands
15+
16+
Usage | Synopsis
17+
---|---
18+
Generate trimmed macOS metadata and embed it in a framework | `$ ns metadata trim macos <Bundle> --analyzer <Path> --generator <Path> [--framework <Path>] [--out <Directory>]`
19+
Generate trimmed iOS metadata | `$ ns metadata trim ios <Bundle> --analyzer <Path> --generator <Path> [--sdk <Sdk>] [--framework <Path>]`
20+
Write the whitelist for an Android project | `$ ns metadata trim android <Bundle> --android-project <Directory>`
21+
22+
### Arguments
23+
24+
* `<Bundle>` - One or more emitted JavaScript files or directories.
25+
26+
### Options
27+
28+
* `--out` - Output directory. Defaults to `metadata-trim`.
29+
* `--keep` - A `Module:Symbol` pattern to keep regardless of the analysis. Repeat for several.
30+
* `--analyzer` - `ns-metadata-symbols` on Apple platforms; overrides the runtime's `seed.js` on Android.
31+
* `--generator` - `objc-metadata-generator`. Use the one from the runtime release the app ships.
32+
* `--framework` - A `NativeScript.framework` to copy into the output directory with the trimmed metadata embedded.
33+
* `--android-project` - The Android project whose `whitelist.mdg` Gradle should use. Build it with `-PnsFilterMetadata`.
34+
* `--sdk` - Xcode SDK name. Defaults to `macosx` or `iphonesimulator`.
35+
* `--arch` - Target architecture. Defaults to `arm64`.
36+
* `--min-version` - Minimum OS version to generate for.

‎lib/bootstrap.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ registerBuiltInCommand<
196196
registerBuiltInCommand<
197197
typeof import("./commands/generate").generateCommandDefinition
198198
>("generate", () => require("./commands/generate").generateCommandDefinition);
199+
registerBuiltInCommand<
200+
typeof import("./commands/metadata-trim").MetadataTrimCommand
201+
>(
202+
"metadata|trim",
203+
() => require("./commands/metadata-trim").MetadataTrimCommand,
204+
);
199205
registerBuiltInCommand<
200206
typeof import("./commands/list-platforms").listPlatformsCommandDefinition
201207
>(
@@ -711,6 +717,7 @@ injector.require(
711717
"metadataFilteringService",
712718
"./services/metadata-filtering-service",
713719
);
720+
injector.require("metadataTrimService", "./services/metadata-trim-service");
714721
injector.require("tempService", "./services/temp-service");
715722

716723
injector.require("sharedEventBus", "./shared-event-bus");

‎lib/commands/metadata-trim.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as path from "path";
2+
import {
3+
arrayOption,
4+
Command,
5+
CommandOptionsSchema,
6+
stringOption,
7+
} from "../common/define-command";
8+
import { inject } from "../common/di";
9+
import { IMetadataTrimService } from "../definitions/metadata-trim-service";
10+
11+
const PLATFORMS = ["ios", "macos", "android"];
12+
13+
const metadataTrimOptions = {
14+
out: stringOption(),
15+
keep: arrayOption(),
16+
analyzer: stringOption(),
17+
generator: stringOption(),
18+
framework: stringOption(),
19+
androidProject: stringOption(),
20+
sdk: stringOption(),
21+
arch: stringOption(),
22+
minVersion: stringOption(),
23+
} satisfies CommandOptionsSchema;
24+
25+
function formatBytes(bytes: number): string {
26+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
27+
}
28+
29+
function resolved(value: string | undefined): string | undefined {
30+
return value ? path.resolve(value) : undefined;
31+
}
32+
33+
export class MetadataTrimCommand extends Command({
34+
name: "metadata|trim",
35+
description: "Reduces native API metadata to what an app's JavaScript uses.",
36+
options: metadataTrimOptions,
37+
params: "any",
38+
}) {
39+
private $logger = inject<ILogger>("logger");
40+
private $metadataTrimService = inject<IMetadataTrimService>(
41+
"metadataTrimService",
42+
);
43+
44+
public canExecute(): boolean {
45+
const [platform, ...bundles] = this.args;
46+
if (!platform || !PLATFORMS.includes(platform.toLowerCase())) {
47+
this.context.fail(`Specify a platform: ${PLATFORMS.join(", ")}.`);
48+
}
49+
if (!bundles.length) {
50+
this.context.fail(
51+
"Specify at least one emitted JavaScript bundle or directory.",
52+
);
53+
}
54+
return true;
55+
}
56+
57+
public async run(): Promise<void> {
58+
const [platform, ...bundles] = this.args;
59+
const options = this.options;
60+
const result = await this.$metadataTrimService.trim({
61+
platform: platform.toLowerCase(),
62+
bundles: bundles.map((bundle) => path.resolve(bundle)),
63+
outDir: path.resolve(options.out ?? "metadata-trim"),
64+
keep: options.keep ?? [],
65+
analyzerPath: resolved(options.analyzer),
66+
generatorPath: resolved(options.generator),
67+
framework: resolved(options.framework),
68+
androidProject: resolved(options.androidProject),
69+
sdk: options.sdk,
70+
arch: options.arch,
71+
minVersion: options.minVersion,
72+
});
73+
74+
if (result.metadataBytes !== undefined) {
75+
this.$logger.info(
76+
`Generated ${formatBytes(result.metadataBytes)} of metadata: ${result.metadataPath}`,
77+
);
78+
}
79+
if (result.installedTo) {
80+
const saved = result.replacedBytes
81+
? ` (${formatBytes(result.replacedBytes)} smaller)`
82+
: "";
83+
this.$logger.info(`Installed to ${result.installedTo}${saved}`);
84+
}
85+
if (result.nextStep) {
86+
this.$logger.info(result.nextStep);
87+
}
88+
}
89+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/** What `ns metadata trim` was asked to do. */
2+
export interface IMetadataTrimOptions {
3+
platform: string;
4+
/** Emitted JavaScript (files or directories) the app actually runs. */
5+
bundles: string[];
6+
outDir: string;
7+
/** Filter patterns (`Module:Symbol`) to keep regardless of what is found. */
8+
keep: string[];
9+
analyzerPath?: string;
10+
generatorPath?: string;
11+
/** A NativeScript.framework to embed the trimmed metadata into. */
12+
framework?: string;
13+
/** An Android project root, the directory that owns `whitelist.mdg`. */
14+
androidProject?: string;
15+
sdk?: string;
16+
arch?: string;
17+
minVersion?: string;
18+
}
19+
20+
/** The native symbols an app's JavaScript can reach. */
21+
export interface IMetadataUsage {
22+
whitelistPath: string;
23+
patternCount: number;
24+
/** The analysis could not bound the app's usage, so nothing can be removed. */
25+
unrestricted: boolean;
26+
}
27+
28+
export interface IMetadataTrimResult {
29+
usage: IMetadataUsage;
30+
/** Set when metadata was generated here rather than by a later build step. */
31+
metadataPath?: string;
32+
metadataBytes?: number;
33+
/** Where the result was installed, when an install target was given. */
34+
installedTo?: string;
35+
replacedBytes?: number;
36+
/** What the caller still has to do for the result to take effect. */
37+
nextStep?: string;
38+
}
39+
40+
/**
41+
* One platform's way of turning usage into metadata. Everything host-specific
42+
* lives behind this interface or in the options, so a new embedder of
43+
* NativeScript needs an install target rather than a new pipeline.
44+
*/
45+
export interface IMetadataToolchain {
46+
readonly platforms: string[];
47+
analyze(options: IMetadataTrimOptions): Promise<IMetadataUsage>;
48+
generate(
49+
usage: IMetadataUsage,
50+
options: IMetadataTrimOptions,
51+
): Promise<Pick<IMetadataTrimResult, "metadataPath" | "metadataBytes">>;
52+
install(
53+
usage: IMetadataUsage,
54+
metadataPath: string | undefined,
55+
options: IMetadataTrimOptions,
56+
): Promise<
57+
Pick<IMetadataTrimResult, "installedTo" | "replacedBytes" | "nextStep">
58+
>;
59+
}
60+
61+
export interface IMetadataTrimService {
62+
trim(options: IMetadataTrimOptions): Promise<IMetadataTrimResult>;
63+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import { IChildProcess, IErrors } from "../common/declarations";
4+
import { injector } from "../common/yok";
5+
import {
6+
IMetadataToolchain,
7+
IMetadataTrimOptions,
8+
IMetadataTrimResult,
9+
IMetadataTrimService,
10+
} from "../definitions/metadata-trim-service";
11+
import { AndroidMetadataToolchain } from "./metadata-trim/android-toolchain";
12+
import { AppleMetadataToolchain } from "./metadata-trim/apple-toolchain";
13+
14+
export class MetadataTrimService implements IMetadataTrimService {
15+
private toolchains: IMetadataToolchain[];
16+
17+
constructor(
18+
$childProcess: IChildProcess,
19+
private $errors: IErrors,
20+
private $logger: ILogger,
21+
) {
22+
this.toolchains = [
23+
new AppleMetadataToolchain($childProcess),
24+
new AndroidMetadataToolchain($childProcess),
25+
];
26+
}
27+
28+
public async trim(
29+
options: IMetadataTrimOptions,
30+
): Promise<IMetadataTrimResult> {
31+
const toolchain = this.toolchains.find((candidate) =>
32+
candidate.platforms.includes(options.platform),
33+
);
34+
if (!toolchain) {
35+
this.$errors.fail(
36+
`Metadata trimming is not available for ${options.platform}.`,
37+
);
38+
}
39+
for (const bundle of options.bundles) {
40+
if (!fs.existsSync(bundle)) {
41+
this.$errors.fail(`Cannot find the bundle ${bundle}.`);
42+
}
43+
}
44+
fs.mkdirSync(path.resolve(options.outDir), { recursive: true });
45+
46+
try {
47+
const usage = await toolchain.analyze(options);
48+
if (usage.unrestricted) {
49+
this.$errors.fail(
50+
"Could not determine which native APIs this app uses, usually because the bundle reads globals dynamically (for example an alias of globalThis, or eval). Nothing was trimmed.",
51+
);
52+
}
53+
this.$logger.info(`Found ${usage.patternCount} native API patterns.`);
54+
const generated = await toolchain.generate(usage, options);
55+
const installed = await toolchain.install(
56+
usage,
57+
generated.metadataPath,
58+
options,
59+
);
60+
return { usage, ...generated, ...installed };
61+
} catch (err) {
62+
this.$errors.fail((err as Error).message);
63+
}
64+
}
65+
}
66+
67+
injector.register("metadataTrimService", MetadataTrimService);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import { IChildProcess } from "../../common/declarations";
4+
import {
5+
IMetadataToolchain,
6+
IMetadataTrimOptions,
7+
IMetadataTrimResult,
8+
IMetadataUsage,
9+
} from "../../definitions/metadata-trim-service";
10+
import { readFilterPatterns } from "./apple-toolchain";
11+
12+
const SEED_SCRIPT = path.join("build-tools", "metadata-filter", "seed.js");
13+
const WHITELIST_NAME = "whitelist.mdg";
14+
15+
/** Android's metadata generator runs in Gradle; this produces its whitelist. */
16+
export class AndroidMetadataToolchain implements IMetadataToolchain {
17+
public readonly platforms = ["android"];
18+
19+
constructor(private $childProcess: IChildProcess) {}
20+
21+
public async analyze(options: IMetadataTrimOptions): Promise<IMetadataUsage> {
22+
if (!options.androidProject) {
23+
throw new Error(
24+
"Pass the Android project that owns whitelist.mdg with --android-project.",
25+
);
26+
}
27+
const project = options.androidProject;
28+
const whitelistPath = path.join(options.outDir, "whitelist.android.mdg");
29+
const args = [
30+
options.analyzerPath ?? path.join(project, SEED_SCRIPT),
31+
...options.bundles,
32+
"--out",
33+
whitelistPath,
34+
"--manifest",
35+
path.join(project, "app", "src", "main", "AndroidManifest.xml"),
36+
];
37+
const resources = path.join(project, "app", "src", "main", "res");
38+
if (fs.existsSync(resources)) {
39+
args.push("--res", resources);
40+
}
41+
await this.$childProcess.spawnFromEvent(process.execPath, args, "close", {
42+
stdio: "pipe",
43+
// The harvester's parser is installed with the project's build tools.
44+
env: {
45+
...process.env,
46+
NODE_PATH: path.join(
47+
project,
48+
"build-tools",
49+
"jsparser",
50+
"node_modules",
51+
),
52+
},
53+
});
54+
55+
// The seed step refuses to write a partial analysis.
56+
if (!fs.existsSync(whitelistPath)) {
57+
return { whitelistPath, patternCount: 0, unrestricted: true };
58+
}
59+
if (options.keep.length) {
60+
fs.appendFileSync(whitelistPath, options.keep.join("\n") + "\n");
61+
}
62+
const patterns = readFilterPatterns(whitelistPath);
63+
return {
64+
whitelistPath,
65+
patternCount: patterns.length,
66+
unrestricted: false,
67+
};
68+
}
69+
70+
public async generate(): Promise<
71+
Pick<IMetadataTrimResult, "metadataPath" | "metadataBytes">
72+
> {
73+
return {};
74+
}
75+
76+
public async install(
77+
usage: IMetadataUsage,
78+
_metadataPath: string | undefined,
79+
options: IMetadataTrimOptions,
80+
): Promise<
81+
Pick<IMetadataTrimResult, "installedTo" | "replacedBytes" | "nextStep">
82+
> {
83+
const installedTo = path.join(options.androidProject, WHITELIST_NAME);
84+
fs.copyFileSync(usage.whitelistPath, installedTo);
85+
return {
86+
installedTo,
87+
// Without this flag Gradle filters by the list as written, dropping
88+
// the supertypes and signature types the listed classes depend on.
89+
nextStep:
90+
"Build with -PnsFilterMetadata so the generator also keeps the types these classes depend on.",
91+
};
92+
}
93+
}

0 commit comments

Comments
 (0)