Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/man_pages/project/configuration/metadata-trim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<% if (isJekyll) { %>---
title: ns metadata trim
position: 26
---<% } %>

# ns metadata trim

### Description

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.

Rules in `native-api-usage.json` still apply. Entries from `whitelist` and from plugins' `uses` are kept in addition to what the analysis finds, and `blacklist` entries are removed. A blacklisted type that a kept API's signature needs can't be removed: on Apple platforms it stays in the metadata, and on Android the build fails and names it. In a NativeScript project these files are found automatically for iOS and Android; other hosts pass them with `--native-api-usage`.

If the bundle reads globals dynamically (an alias of `globalThis`, for example), its usage cannot be determined and nothing is trimmed.

### Commands

Usage | Synopsis
---|---
Generate trimmed macOS metadata and embed it in a framework | `$ ns metadata trim macos <Bundle> --analyzer <Path> --generator <Path> [--framework <Path>] [--out <Directory>]`
Generate trimmed iOS metadata | `$ ns metadata trim ios <Bundle> --analyzer <Path> --generator <Path> [--sdk <Sdk>] [--framework <Path>]`
Write the whitelist for an Android project | `$ ns metadata trim android <Bundle> --android-project <Directory>`

### Arguments

* `<Bundle>` - One or more emitted JavaScript files or directories.

### Options

* `--out` - Output directory. Defaults to `metadata-trim`.
* `--keep` - A `Module:Symbol` pattern to keep regardless of the analysis. Repeat for several.
* `--native-api-usage` - A `native-api-usage.json` file to apply, in the app or plugin format. Repeat for several.
* `--analyzer` - `ns-metadata-symbols` on Apple platforms; overrides the runtime's `seed.js` on Android.
* `--generator` - `objc-metadata-generator`. Use the one from the runtime release the app ships.
* `--framework` - A `NativeScript.framework` to copy into the output directory with the trimmed metadata embedded.
* `--android-project` - The Android project whose `whitelist.mdg` Gradle should use. Build it with `-PnsFilterMetadata`.
* `--sdk` - Xcode SDK name. Defaults to `macosx` or `iphonesimulator`.
* `--arch` - Target architecture. Defaults to `arm64`.
* `--min-version` - Minimum OS version to generate for.
7 changes: 7 additions & 0 deletions lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ registerBuiltInCommand<
registerBuiltInCommand<
typeof import("./commands/generate").generateCommandDefinition
>("generate", () => require("./commands/generate").generateCommandDefinition);
registerBuiltInCommand<
typeof import("./commands/metadata-trim").MetadataTrimCommand
>(
"metadata|trim",
() => require("./commands/metadata-trim").MetadataTrimCommand,
);
registerBuiltInCommand<
typeof import("./commands/list-platforms").listPlatformsCommandDefinition
>(
Expand Down Expand Up @@ -711,6 +717,7 @@ injector.require(
"metadataFilteringService",
"./services/metadata-filtering-service",
);
injector.require("metadataTrimService", "./services/metadata-trim-service");
injector.require("tempService", "./services/temp-service");

injector.require("sharedEventBus", "./shared-event-bus");
Expand Down
129 changes: 129 additions & 0 deletions lib/commands/metadata-trim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as path from "path";
import {
arrayOption,
Command,
CommandOptionsSchema,
stringOption,
} from "../common/define-command";
import { inject } from "../common/di";
import { IMetadataFilteringService } from "../definitions/metadata-filtering-service";
import {
IDeclaredMetadataRules,
IMetadataTrimService,
} from "../definitions/metadata-trim-service";
import { IProjectData } from "../definitions/project";
import { IProjectHelper } from "../common/declarations";
import { readDeclaredRules } from "../services/metadata-trim/declared-rules";

const PLATFORMS = ["ios", "macos", "android"];

const metadataTrimOptions = {
out: stringOption(),
keep: arrayOption(),
nativeApiUsage: arrayOption(),
analyzer: stringOption(),
generator: stringOption(),
framework: stringOption(),
androidProject: stringOption(),
sdk: stringOption(),
arch: stringOption(),
minVersion: stringOption(),
} satisfies CommandOptionsSchema;

function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

function resolved(value: string | undefined): string | undefined {
return value ? path.resolve(value) : undefined;
}

export class MetadataTrimCommand extends Command({
name: "metadata|trim",
description: "Reduces native API metadata to what an app's JavaScript uses.",
options: metadataTrimOptions,
params: "any",
}) {
private $logger = inject<ILogger>("logger");
private $metadataFilteringService = inject<IMetadataFilteringService>(
"metadataFilteringService",
);
private $metadataTrimService = inject<IMetadataTrimService>(
"metadataTrimService",
);
private $projectData = inject<IProjectData>("projectData");
private $projectHelper = inject<IProjectHelper>("projectHelper");

public canExecute(): boolean {
const [platform, ...bundles] = this.args;
if (!platform || !PLATFORMS.includes(platform.toLowerCase())) {
this.context.fail(`Specify a platform: ${PLATFORMS.join(", ")}.`);
}
if (!bundles.length) {
this.context.fail(
"Specify at least one emitted JavaScript bundle or directory.",
);
}
return true;
}

public async run(): Promise<void> {
const [platform, ...bundles] = this.args;
const options = this.options;
const declared = this.declaredRules(platform.toLowerCase());
const result = await this.$metadataTrimService.trim({
platform: platform.toLowerCase(),
bundles: bundles.map((bundle) => path.resolve(bundle)),
outDir: path.resolve(options.out ?? "metadata-trim"),
keep: [...(options.keep ?? []), ...declared.keep],
blacklist: declared.blacklist,
analyzerPath: resolved(options.analyzer),
generatorPath: resolved(options.generator),
framework: resolved(options.framework),
androidProject: resolved(options.androidProject),
sdk: options.sdk,
arch: options.arch,
minVersion: options.minVersion,
});

if (declared.keep.length || declared.blacklist.length) {
this.$logger.info(
`Applied native-api-usage.json: ${declared.keep.length} kept, ${declared.blacklist.length} removed.`,
);
}
if (result.metadataBytes !== undefined) {
this.$logger.info(
`Generated ${formatBytes(result.metadataBytes)} of metadata: ${result.metadataPath}`,
);
}
if (result.installedTo) {
const saved = result.replacedBytes
? ` (${formatBytes(result.replacedBytes)} smaller)`
: "";
this.$logger.info(`Installed to ${result.installedTo}${saved}`);
}
if (result.nextStep) {
this.$logger.info(result.nextStep);
}
}

private declaredRules(platform: string): IDeclaredMetadataRules {
const files = (this.options.nativeApiUsage ?? []).map((file) =>
readDeclaredRules(path.resolve(file)),
);
// A standard project's files only exist for iOS and Android.
if (this.$projectHelper.projectDir && platform !== "macos") {
this.$projectData.initializeProjectData();
files.push(
this.$metadataFilteringService.getDeclaredRules(
this.$projectData,
platform,
),
);
}
return {
keep: files.flatMap((rules) => rules.keep),
blacklist: files.flatMap((rules) => rules.blacklist),
};
}
}
12 changes: 12 additions & 0 deletions lib/definitions/metadata-filtering-service.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IProjectData } from "./project";
import { IDeclaredMetadataRules } from "./metadata-trim-service";

/**
* Describes service used to generate necessary files to filter the native metadata generation.
Expand Down Expand Up @@ -45,4 +46,15 @@ interface IMetadataFilteringService {
* @returns {void}
*/
generateMetadataFilters(projectData: IProjectData, platform: string): void;

/**
* Reads the rules the app and its plugins declare in native-api-usage.json.
* @param {IProjectData} projectData Information about the current project.
* @param {string} platform The platform to read rules for.
* @returns {IDeclaredMetadataRules} The patterns to keep and to remove.
*/
getDeclaredRules(
projectData: IProjectData,
platform: string,
): IDeclaredMetadataRules;
}
71 changes: 71 additions & 0 deletions lib/definitions/metadata-trim-service.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** What `ns metadata trim` was asked to do. */
export interface IMetadataTrimOptions {
platform: string;
/** Emitted JavaScript (files or directories) the app actually runs. */
bundles: string[];
outDir: string;
/** Filter patterns (`Module:Symbol`) to keep regardless of what is found. */
keep: string[];
/** Filter patterns to remove even if the app references them. */
blacklist?: string[];
analyzerPath?: string;
generatorPath?: string;
/** A NativeScript.framework to embed the trimmed metadata into. */
framework?: string;
/** An Android project root, the directory that owns `whitelist.mdg`. */
androidProject?: string;
sdk?: string;
arch?: string;
minVersion?: string;
}

/** What native-api-usage.json files declare, as filter patterns. */
export interface IDeclaredMetadataRules {
keep: string[];
blacklist: string[];
}

/** The native symbols an app's JavaScript can reach. */
export interface IMetadataUsage {
whitelistPath: string;
patternCount: number;
/** The analysis could not bound the app's usage, so nothing can be removed. */
unrestricted: boolean;
}

export interface IMetadataTrimResult {
usage: IMetadataUsage;
/** Set when metadata was generated here rather than by a later build step. */
metadataPath?: string;
metadataBytes?: number;
/** Where the result was installed, when an install target was given. */
installedTo?: string;
replacedBytes?: number;
/** What the caller still has to do for the result to take effect. */
nextStep?: string;
}

/**
* One platform's way of turning usage into metadata. Everything host-specific
* lives behind this interface or in the options, so a new embedder of
* NativeScript needs an install target rather than a new pipeline.
*/
export interface IMetadataToolchain {
readonly platforms: string[];
analyze(options: IMetadataTrimOptions): Promise<IMetadataUsage>;
generate(
usage: IMetadataUsage,
options: IMetadataTrimOptions,
): Promise<Pick<IMetadataTrimResult, "metadataPath" | "metadataBytes">>;
install(
usage: IMetadataUsage,
metadataPath: string | undefined,
options: IMetadataTrimOptions,
): Promise<
Pick<IMetadataTrimResult, "installedTo" | "replacedBytes" | "nextStep">
>;
}

export interface IMetadataTrimService {
trim(options: IMetadataTrimOptions): Promise<IMetadataTrimResult>;
}
Loading
Loading