voluptasmollitia
Version:
Monorepo for the Firebase JavaScript SDK
957 lines (910 loc) • 33.7 kB
text/typescript
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as tmp from 'tmp';
import * as path from 'path';
import * as fs from 'fs';
import * as rollup from 'rollup';
import * as terser from 'terser';
import * as ts from 'typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { deepCopy } from '@firebase/util';
export const enum ErrorCode {
INVALID_FLAG_COMBINATION = 'Invalid command flag combinations!',
BUNDLE_FILE_DOES_NOT_EXIST = 'Module does not have a bundle file!',
DTS_FILE_DOES_NOT_EXIST = 'Module does not have a dts file!',
OUTPUT_DIRECTORY_REQUIRED = 'An output directory is required but a file given!',
OUTPUT_FILE_REQUIRED = 'An output file is required but a directory given!',
INPUT_FILE_DOES_NOT_EXIST = 'Input file does not exist!',
INPUT_DTS_FILE_DOES_NOT_EXIST = 'Input dts file does not exist!',
INPUT_BUNDLE_FILE_DOES_NOT_EXIST = 'Input bundle file does not exist!',
FILE_PARSING_ERROR = 'Failed to parse js file!',
PKG_JSON_DOES_NOT_EXIST = 'Module does not have a package.json file!',
TYPINGS_FIELD_NOT_DEFINED = 'Module does not have typings field defined in its package.json!'
}
/** Contains a list of members by type. */
export interface MemberList {
classes: string[];
functions: string[];
variables: string[];
enums: string[];
}
/** Contains the dependencies and the size of their code for a single export. */
export interface ExportData {
name: string;
classes: string[];
functions: string[];
variables: string[];
enums: string[];
externals: { [key: string]: string[] };
size: number;
sizeWithExtDeps: number;
}
export interface Report {
name: string;
symbols: ExportData[];
}
/**
* Helper for extractDependencies that extracts the dependencies and the size
* of the minified build.
*/
export async function extractDependenciesAndSize(
exportName: string,
jsBundle: string,
map: Map<string, string>
): Promise<ExportData> {
const input = tmp.fileSync().name + '.js';
const externalDepsResolvedOutput = tmp.fileSync().name + '.js';
const externalDepsNotResolvedOutput = tmp.fileSync().name + '.js';
const exportStatement = `export { ${exportName} } from '${path.resolve(
jsBundle
)}';`;
fs.writeFileSync(input, exportStatement);
// Run Rollup on the JavaScript above to produce a tree-shaken build
const externalDepsResolvedBundle = await rollup.rollup({
input,
plugins: [
resolve({
mainFields: ['esm2017', 'module', 'main']
}),
commonjs()
]
});
await externalDepsResolvedBundle.write({
file: externalDepsResolvedOutput,
format: 'es'
});
const externalDepsNotResolvedBundle = await rollup.rollup({
input,
// exclude all firebase dependencies and tslib
external: id => id.startsWith('@firebase') || id === 'tslib'
});
await externalDepsNotResolvedBundle.write({
file: externalDepsNotResolvedOutput,
format: 'es'
});
const dependencies: MemberList = extractDeclarations(
externalDepsNotResolvedOutput,
map
);
const externalDepsResolvedOutputContent = fs.readFileSync(
externalDepsResolvedOutput,
'utf-8'
);
// Extract size of minified build
const externalDepsNotResolvedOutputContent = fs.readFileSync(
externalDepsNotResolvedOutput,
'utf-8'
);
const externalDepsResolvedOutputContentMinimized = await terser.minify(
externalDepsResolvedOutputContent,
{
format: {
comments: false
},
mangle: { toplevel: true },
compress: false
}
);
const externalDepsNotResolvedOutputContentMinimized = await terser.minify(
externalDepsNotResolvedOutputContent,
{
format: {
comments: false
},
mangle: { toplevel: true },
compress: false
}
);
const exportData: ExportData = {
name: '',
classes: [],
functions: [],
variables: [],
enums: [],
externals: {},
size: 0,
sizeWithExtDeps: 0
};
exportData.name = exportName;
for (const key of Object.keys(dependencies) as Array<keyof MemberList>) {
exportData[key] = dependencies[key];
}
exportData.externals = extractExternalDependencies(
externalDepsNotResolvedOutput
);
exportData.size = Buffer.byteLength(
externalDepsNotResolvedOutputContentMinimized.code!,
'utf-8'
);
exportData.sizeWithExtDeps = Buffer.byteLength(
externalDepsResolvedOutputContentMinimized.code!,
'utf-8'
);
fs.unlinkSync(input);
fs.unlinkSync(externalDepsNotResolvedOutput);
fs.unlinkSync(externalDepsResolvedOutput);
return exportData;
}
/**
* Extracts all function, class and variable declarations using the TypeScript
* compiler API.
* @param map maps every symbol listed in dts file to its type. eg: aVariable -> variable.
* map is null when given filePath is a path to d.ts file.
* map is populated when given filePath points to a .js bundle file.
*
* Examples of Various Type of Exports
* FunctionDeclaration: export function aFunc(): string {...};
* ClassDeclaration: export class aClass {};
* EnumDeclaration: export enum aEnum {};
* VariableDeclaration: export let aVariable: string; import * as tmp from 'tmp'; export declare const aVar: tmp.someType.
* VariableStatement: export const aVarStatement: string = "string"; export const { a, b } = { a: 'a', b: 'b' };
* ExportDeclaration:
* named exports: export {foo, bar} from '...'; export {foo as foo1, bar} from '...'; export {LogLevel};
* export everything: export * from '...';
*/
export function extractDeclarations(
filePath: string,
map?: Map<string, string>
): MemberList {
const program = ts.createProgram([filePath], { allowJs: true });
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) {
throw new Error(`${ErrorCode.FILE_PARSING_ERROR} ${filePath}`);
}
let declarations: MemberList = {
functions: [],
classes: [],
variables: [],
enums: []
};
const namespaceImportSet: Set<string> = new Set();
// define a map here which is used to handle export statements that have no from clause.
// As there is no from clause in such export statements, we retrieve symbol location by parsing the corresponding import
// statements. We store the symbol and its defined location as key value pairs in the map.
const importSymbolCurrentNameToModuleLocation: Map<
string,
string
> = new Map();
const importSymbolCurrentNameToOriginalName: Map<string, string> = new Map(); // key: current name value: original name
const importModuleLocationToExportedSymbolsList: Map<
string,
MemberList
> = new Map(); // key: module location, value: a list of all exported symbols of the module
ts.forEachChild(sourceFile, node => {
if (ts.isFunctionDeclaration(node)) {
declarations.functions.push(node.name!.text);
} else if (ts.isClassDeclaration(node)) {
declarations.classes.push(node.name!.text);
} else if (ts.isVariableDeclaration(node)) {
declarations.variables.push(node.name!.getText());
} else if (ts.isEnumDeclaration(node)) {
// `const enum`s should not be analyzed. They do not add to bundle size and
// creating a file that imports them causes an error during the rollup step.
if (
// Identifies if this enum had a "const" modifier attached.
!node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ConstKeyword)
) {
declarations.enums.push(node.name.escapedText.toString());
}
} else if (ts.isVariableStatement(node)) {
const variableDeclarations = node.declarationList.declarations;
variableDeclarations.forEach(variableDeclaration => {
//variableDeclaration.name could be of Identifier type or of BindingPattern type
// Identifier Example: export const a: string = "aString";
if (ts.isIdentifier(variableDeclaration.name)) {
declarations.variables.push(
variableDeclaration.name.getText(sourceFile)
);
}
// Binding Pattern Example: export const {a, b} = {a: 1, b: 1};
else {
const elements = variableDeclaration.name
.elements as ts.NodeArray<ts.BindingElement>;
elements.forEach((node: ts.BindingElement) => {
declarations.variables.push(node.name.getText(sourceFile));
});
}
});
} else if (ts.isImportDeclaration(node) && node.importClause) {
const symbol = checker.getSymbolAtLocation(node.moduleSpecifier);
if (symbol && symbol.valueDeclaration) {
const importFilePath = symbol.valueDeclaration.getSourceFile().fileName;
// import { a, b } from '@firebase/dummy-exp'
// import {a as A, b as B} from '@firebase/dummy-exp'
if (
node.importClause.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings)
) {
node.importClause.namedBindings.elements.forEach(each => {
const symbolName: string = each.name.getText(sourceFile); // import symbol current name
importSymbolCurrentNameToModuleLocation.set(
symbolName,
importFilePath
);
// if imported symbols are renamed, insert an entry to importSymbolCurrentNameToOriginalName Map
// with key the current name, value the original name
if (each.propertyName) {
importSymbolCurrentNameToOriginalName.set(
symbolName,
each.propertyName.getText(sourceFile)
);
}
});
// import * as fs from 'fs'
} else if (
node.importClause.namedBindings &&
ts.isNamespaceImport(node.importClause.namedBindings)
) {
const symbolName: string = node.importClause.namedBindings.name.getText(
sourceFile
);
namespaceImportSet.add(symbolName);
// import a from '@firebase/dummy-exp'
} else if (
node.importClause.name &&
ts.isIdentifier(node.importClause.name)
) {
const symbolName: string = node.importClause.name.getText(sourceFile);
importSymbolCurrentNameToModuleLocation.set(
symbolName,
importFilePath
);
}
}
}
// re-exports handler: handles cases like :
// export {LogLevel};
// export * from '..';
// export {foo, bar} from '..';
// export {foo as foo1, bar} from '...';
else if (ts.isExportDeclaration(node)) {
// this clause handles the export statements that have a from clause (referred to as moduleSpecifier in ts compiler).
// examples are "export {foo as foo1, bar} from '...';"
// and "export * from '..';"
if (node.moduleSpecifier) {
if (ts.isStringLiteral(node.moduleSpecifier)) {
const reExportsWithFromClause: MemberList = handleExportStatementsWithFromClause(
checker,
node,
node.moduleSpecifier.getText(sourceFile)
);
// concatenate re-exported MemberList with MemberList of the dts file
for (const key of Object.keys(declarations) as Array<
keyof MemberList
>) {
declarations[key].push(...reExportsWithFromClause[key]);
}
}
} else {
// export {LogLevel};
// exclusively handles named export statements that has no from clause.
handleExportStatementsWithoutFromClause(
node,
importSymbolCurrentNameToModuleLocation,
importSymbolCurrentNameToOriginalName,
importModuleLocationToExportedSymbolsList,
namespaceImportSet,
declarations
);
}
}
});
declarations = dedup(declarations);
if (map) {
declarations = mapSymbolToType(map, declarations);
}
//Sort to ensure stable output
Object.values(declarations).map(each => {
each.sort();
});
return declarations;
}
/**
*
* @param node compiler representation of an export statement
*
* This function exclusively handles export statements that have a from clause. The function uses checker argument to resolve
* module name specified in from clause to its actual location. It then retrieves all exported symbols from the module.
* If the statement is a named export, the function does an extra step, that is, filtering out the symbols that are not listed
* in exportClause.
*/
function handleExportStatementsWithFromClause(
checker: ts.TypeChecker,
node: ts.ExportDeclaration,
moduleName: string
): MemberList {
const symbol = checker.getSymbolAtLocation(node.moduleSpecifier!);
let declarations: MemberList = {
functions: [],
classes: [],
variables: [],
enums: []
};
if (symbol && symbol.valueDeclaration) {
const reExportFullPath = symbol.valueDeclaration.getSourceFile().fileName;
// first step: always retrieve all exported symbols from the source location of the re-export.
declarations = extractDeclarations(reExportFullPath);
// if it's a named export statement, filter the MemberList to keep only those listed in exportClause.
// named exports: eg: export {foo, bar} from '...'; and export {foo as foo1, bar} from '...';
declarations = extractSymbolsFromNamedExportStatement(node, declarations);
}
// if the module name in the from clause cant be resolved to actual module location,
// just extract symbols listed in the exportClause for named exports, put them in variables first, as
// they will be categorized later using map argument.
else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach(exportSpecifier => {
declarations.variables.push(exportSpecifier.name.escapedText.toString());
});
}
// handles the case when exporting * from a module whose location can't be resolved
else {
console.log(
`The public API extraction of ${moduleName} is not complete, because it re-exports from ${moduleName} using * export but we couldn't resolve ${moduleName}`
);
}
return declarations;
}
/**
*
* @param node compiler representation of a named export statement
* @param exportsFullList a list of all exported symbols retrieved from the location given in the export statement.
*
* This function filters on exportsFullList and keeps only those symbols that are listed in the given named export statement.
*/
function extractSymbolsFromNamedExportStatement(
node: ts.ExportDeclaration,
exportsFullList: MemberList
): MemberList {
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
const actualExports: string[] = [];
node.exportClause.elements.forEach(exportSpecifier => {
const reExportedSymbol: string = extractOriginalSymbolName(
exportSpecifier
);
// eg: export {foo as foo1 } from '...';
// if export is renamed, replace with new name
// reExportedSymbol: stores the original symbol name
// exportSpecifier.name: stores the renamed symbol name
if (isExportRenamed(exportSpecifier)) {
actualExports.push(exportSpecifier.name.escapedText.toString());
// reExportsMember stores all re-exported symbols in its orignal name. However, these re-exported symbols
// could be renamed by the re-export. We want to show the renamed name of the symbols in the final analysis report.
// Therefore, replaceAll simply replaces the original name of the symbol with the new name defined in re-export.
replaceAll(
exportsFullList,
reExportedSymbol,
exportSpecifier.name.escapedText.toString()
);
} else {
actualExports.push(reExportedSymbol);
}
});
// for named exports: requires a filter step which keeps only the symbols listed in the export statement.
filterAllBy(exportsFullList, actualExports);
}
return exportsFullList;
}
/**
* @param node compiler representation of a named export statement
* @param importSymbolCurrentNameToModuleLocation a map with imported symbol current name as key and the resolved module location as value. (map is populated by parsing import statements)
* @param importSymbolCurrentNameToOriginalName as imported symbols can be renamed, this map stores imported symbols current name and original name as key value pairs.
* @param importModuleLocationToExportedSymbolsList a map that maps module location to a list of its exported symbols.
* @param namespaceImportSymbolSet a set of namespace import symbols.
* @param parentDeclarations a list of exported symbols extracted from the module so far
* This function exclusively handles named export statements that has no from clause, i.e: statements like export {LogLevel};
* first case: namespace export
* example: import * as fs from 'fs'; export {fs};
* The function checks if namespaceImportSymbolSet has a namespace import symbol that of the same name, append the symbol to declarations.variables if exists.
*
* second case: import then export
* example: import {a} from '...'; export {a}
* The function retrieves the location where the exported symbol is defined from the corresponding import statements.
*
* third case: declare first then export
* examples: declare const apps: Map<string, number>; export { apps };
* function foo(){} ; export {foo as bar};
* The function parses export clause of the statement and replaces symbol with its current name (if the symbol is renamed) from the declaration argument.
*/
function handleExportStatementsWithoutFromClause(
node: ts.ExportDeclaration,
importSymbolCurrentNameToModuleLocation: Map<string, string>,
importSymbolCurrentNameToOriginalName: Map<string, string>,
importModuleLocationToExportedSymbolsList: Map<string, MemberList>,
namespaceImportSymbolSet: Set<string>,
parentDeclarations: MemberList
): void {
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach(exportSpecifier => {
// export symbol could be renamed, we retrieve both its current/renamed name and original name
const exportSymbolCurrentName = exportSpecifier.name.escapedText.toString();
const exportSymbolOriginalName = extractOriginalSymbolName(
exportSpecifier
);
// import * as fs from 'fs'; export {fs};
if (namespaceImportSymbolSet.has(exportSymbolOriginalName)) {
parentDeclarations.variables.push(exportSymbolOriginalName);
replaceAll(
parentDeclarations,
exportSymbolOriginalName,
exportSymbolCurrentName
);
}
// handles import then exports
// import {a as A , b as B} from '...'
// export {A as AA , B as BB };
else if (
importSymbolCurrentNameToModuleLocation.has(exportSymbolOriginalName)
) {
const moduleLocation: string = importSymbolCurrentNameToModuleLocation.get(
exportSymbolOriginalName
)!;
let reExportedSymbols: MemberList;
if (importModuleLocationToExportedSymbolsList.has(moduleLocation)) {
reExportedSymbols = deepCopy(
importModuleLocationToExportedSymbolsList.get(moduleLocation)!
);
} else {
reExportedSymbols = extractDeclarations(
importSymbolCurrentNameToModuleLocation.get(
exportSymbolOriginalName
)!
);
importModuleLocationToExportedSymbolsList.set(
moduleLocation,
deepCopy(reExportedSymbols)
);
}
let nameToBeReplaced = exportSymbolOriginalName;
// if current exported symbol is renamed in import clause. then we retrieve its original name from
// importSymbolCurrentNameToOriginalName map
if (
importSymbolCurrentNameToOriginalName.has(exportSymbolOriginalName)
) {
nameToBeReplaced = importSymbolCurrentNameToOriginalName.get(
exportSymbolOriginalName
)!;
}
filterAllBy(reExportedSymbols, [nameToBeReplaced]);
// replace with new name
replaceAll(
reExportedSymbols,
nameToBeReplaced,
exportSymbolCurrentName
);
// concatenate re-exported MemberList with MemberList of the dts file
for (const key of Object.keys(parentDeclarations) as Array<
keyof MemberList
>) {
parentDeclarations[key].push(...reExportedSymbols[key]);
}
}
// handles declare first then export
// declare const apps: Map<string, number>;
// export { apps as apps1};
// function a() {};
// export {a};
else {
if (isExportRenamed(exportSpecifier)) {
replaceAll(
parentDeclarations,
exportSymbolOriginalName,
exportSymbolCurrentName
);
}
}
});
}
}
/**
* To Make sure symbols of every category are unique.
*/
export function dedup(memberList: MemberList): MemberList {
for (const key of Object.keys(memberList) as Array<keyof MemberList>) {
const set: Set<string> = new Set(memberList[key]);
memberList[key] = Array.from(set);
}
return memberList;
}
export function mapSymbolToType(
map: Map<string, string>,
memberList: MemberList
): MemberList {
const newMemberList: MemberList = {
functions: [],
classes: [],
variables: [],
enums: []
};
for (const key of Object.keys(memberList) as Array<keyof MemberList>) {
memberList[key].forEach((element: string) => {
if (map.has(element)) {
newMemberList[map.get(element)! as keyof MemberList].push(element);
} else {
newMemberList[key].push(element);
}
});
}
return newMemberList;
}
function extractOriginalSymbolName(
exportSpecifier: ts.ExportSpecifier
): string {
// if symbol is renamed, then exportSpecifier.propertyName is not null and stores the orignal name, exportSpecifier.name stores the renamed name.
// if symbol is not renamed, then exportSpecifier.propertyName is null, exportSpecifier.name stores the orignal name.
if (exportSpecifier.propertyName) {
return exportSpecifier.propertyName.escapedText.toString();
}
return exportSpecifier.name.escapedText.toString();
}
function filterAllBy(memberList: MemberList, keep: string[]): void {
for (const key of Object.keys(memberList) as Array<keyof MemberList>) {
memberList[key] = memberList[key].filter(each => keep.includes(each));
}
}
export function replaceAll(
memberList: MemberList,
original: string,
current: string
): void {
for (const key of Object.keys(memberList) as Array<keyof MemberList>) {
memberList[key] = replaceWith(memberList[key], original, current);
}
}
function replaceWith(
arr: string[],
original: string,
current: string
): string[] {
const rv: string[] = [];
for (const each of arr) {
if (each.localeCompare(original) === 0) {
rv.push(current);
} else {
rv.push(each);
}
}
return rv;
}
function isExportRenamed(exportSpecifier: ts.ExportSpecifier): boolean {
return exportSpecifier.propertyName != null;
}
/**
*
* This functions writes generated json report(s) to a file
*/
export function writeReportToFile(report: Report, outputFile: string): void {
if (fs.existsSync(outputFile) && !fs.lstatSync(outputFile).isFile()) {
throw new Error(ErrorCode.OUTPUT_FILE_REQUIRED);
}
const directoryPath = path.dirname(outputFile);
//for output file path like ./dir/dir1/dir2/file, we need to make sure parent dirs exist.
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, { recursive: true });
}
fs.writeFileSync(outputFile, JSON.stringify(report, null, 4));
}
/**
*
* This functions writes generated json report(s) to a file of given directory
*/
export function writeReportToDirectory(
report: Report,
fileName: string,
directoryPath: string
): void {
if (
fs.existsSync(directoryPath) &&
!fs.lstatSync(directoryPath).isDirectory()
) {
throw new Error(ErrorCode.OUTPUT_DIRECTORY_REQUIRED);
}
writeReportToFile(report, `${directoryPath}/${fileName}`);
}
/**
* This function extract unresolved external module symbols from bundle file import statements.
*
*/
export function extractExternalDependencies(
minimizedBundleFile: string
): { [key: string]: string[] } {
const program = ts.createProgram([minimizedBundleFile], { allowJs: true });
const sourceFile = program.getSourceFile(minimizedBundleFile);
if (!sourceFile) {
throw new Error(`${ErrorCode.FILE_PARSING_ERROR} ${minimizedBundleFile}`);
}
const externalsMap: Map<string, string[]> = new Map();
ts.forEachChild(sourceFile, node => {
if (ts.isImportDeclaration(node) && node.importClause) {
const moduleName: string = node.moduleSpecifier.getText(sourceFile);
if (!externalsMap.has(moduleName)) {
externalsMap.set(moduleName, []);
}
//import {a, b } from '@firebase/dummy-exp';
// import {a as c, b } from '@firebase/dummy-exp';
if (
node.importClause.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings)
) {
node.importClause.namedBindings.elements.forEach(each => {
// if imported symbol is renamed, we want its original name which is stored in propertyName
if (each.propertyName) {
externalsMap
.get(moduleName)!
.push(each.propertyName.getText(sourceFile));
} else {
externalsMap.get(moduleName)!.push(each.name.getText(sourceFile));
}
});
// import * as fs from 'fs'
} else if (
node.importClause.namedBindings &&
ts.isNamespaceImport(node.importClause.namedBindings)
) {
externalsMap.get(moduleName)!.push('*');
// import a from '@firebase/dummy-exp'
} else if (
node.importClause.name &&
ts.isIdentifier(node.importClause.name)
) {
externalsMap.get(moduleName)!.push('default export');
}
}
});
const externals: { [key: string]: string[] } = {};
externalsMap.forEach((value, key) => {
externals[key.replace(/'/g, '')] = value;
});
return externals;
}
/**
* This function generates a binary size report for the given module specified by the moduleLocation argument.
* @param moduleLocation a path to location of a firebase module
*/
export async function generateReportForModule(
moduleLocation: string
): Promise<Report> {
const packageJsonPath = `${moduleLocation}/package.json`;
if (!fs.existsSync(packageJsonPath)) {
throw new Error(
`Firebase Module locates at ${moduleLocation}: ${ErrorCode.PKG_JSON_DOES_NOT_EXIST}`
);
}
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, { encoding: 'utf-8' })
);
// to exclude <modules>-types modules
const TYPINGS: string = 'typings';
if (packageJson[TYPINGS]) {
const dtsFile = `${moduleLocation}/${packageJson[TYPINGS]}`;
const bundleLocation: string = retrieveBundleFileLocation(packageJson);
if (!bundleLocation) {
throw new Error(ErrorCode.BUNDLE_FILE_DOES_NOT_EXIST);
}
const bundleFile = `${moduleLocation}/${bundleLocation}`;
const jsonReport: Report = await generateReport(
packageJson.name,
dtsFile,
bundleFile
);
return jsonReport;
}
throw new Error(
`Firebase Module locates at: ${moduleLocation}: ${ErrorCode.TYPINGS_FIELD_NOT_DEFINED}`
);
}
/**
*
* @param pkgJson package.json of the module.
*
* This function implements a fallback of locating module's budle file.
* It first looks at esm2017 field of package.json, then module field. Main
* field at the last.
*
*/
function retrieveBundleFileLocation(pkgJson: {
[key: string]: string;
}): string {
if (pkgJson['esm2017']) {
return pkgJson['esm2017'];
}
if (pkgJson['module']) {
return pkgJson['module'];
}
if (pkgJson['main']) {
return pkgJson['main'];
}
return '';
}
/**
*
* This function creates a map from a MemberList object which maps symbol names (key) listed
* to its type (value)
*/
export function buildMap(api: MemberList): Map<string, string> {
const map: Map<string, string> = new Map();
for (const type of Object.keys(api) as Array<keyof MemberList>) {
api[type].forEach((element: string) => {
map.set(element, type);
});
}
return map;
}
/**
* A recursive function that locates and generates reports for sub-modules
*/
async function traverseDirs(
moduleLocation: string,
// eslint-disable-next-line @typescript-eslint/ban-types
executor: Function,
level: number,
levelLimit: number
): Promise<Report[]> {
if (level > levelLimit) {
return [];
}
const reports: Report[] = [];
const report: Report = await executor(moduleLocation);
if (report != null) {
reports.push(report);
}
for (const name of fs.readdirSync(moduleLocation)) {
const p = `${moduleLocation}/${name}`;
const generateSizeAnalysisReportPkgJsonField: string =
'generate-size-analysis-report';
// submodules of a firebase module should set generate-size-analysis-report field of package.json to true
// in order to be analyzed
if (
fs.lstatSync(p).isDirectory() &&
fs.existsSync(`${p}/package.json`) &&
JSON.parse(fs.readFileSync(`${p}/package.json`, { encoding: 'utf-8' }))[
generateSizeAnalysisReportPkgJsonField
]
) {
const subModuleReports: Report[] = await traverseDirs(
p,
executor,
level + 1,
levelLimit
);
if (subModuleReports !== null && subModuleReports.length !== 0) {
reports.push(...subModuleReports);
}
}
}
return reports;
}
/**
*
* This functions generates the final json report for the module.
* @param publicApi all symbols extracted from the input dts file.
* @param jsFile a bundle file generated by rollup according to the input dts file.
* @param map maps every symbol listed in publicApi to its type. eg: aVariable -> variable.
*/
export async function buildJsonReport(
moduleName: string,
publicApi: MemberList,
jsFile: string,
map: Map<string, string>
): Promise<Report> {
const result: Report = {
name: moduleName,
symbols: []
};
for (const exp of publicApi.classes) {
try {
result.symbols.push(await extractDependenciesAndSize(exp, jsFile, map));
} catch (e) {
console.log(e);
}
}
for (const exp of publicApi.functions) {
try {
result.symbols.push(await extractDependenciesAndSize(exp, jsFile, map));
} catch (e) {
console.log(e);
}
}
for (const exp of publicApi.variables) {
try {
result.symbols.push(await extractDependenciesAndSize(exp, jsFile, map));
} catch (e) {
console.log(e);
}
}
for (const exp of publicApi.enums) {
try {
result.symbols.push(await extractDependenciesAndSize(exp, jsFile, map));
} catch (e) {
console.log(e);
}
}
return result;
}
/**
*
* This function generates a report from given dts file.
* @param name a name to be displayed on the report. a module name if for a firebase module; a random name if for adhoc analysis.
* @param dtsFile absolute path to the definition file of interest.
* @param bundleFile absolute path to the bundle file of the given definition file.
*/
export async function generateReport(
name: string,
dtsFile: string,
bundleFile: string
): Promise<Report> {
const resolvedDtsFile = path.resolve(dtsFile);
const resolvedBundleFile = path.resolve(bundleFile);
if (!fs.existsSync(resolvedDtsFile)) {
throw new Error(ErrorCode.INPUT_DTS_FILE_DOES_NOT_EXIST);
}
if (!fs.existsSync(resolvedBundleFile)) {
throw new Error(ErrorCode.INPUT_BUNDLE_FILE_DOES_NOT_EXIST);
}
const publicAPI = extractDeclarations(resolvedDtsFile);
const map: Map<string, string> = buildMap(publicAPI);
return buildJsonReport(name, publicAPI, bundleFile, map);
}
/**
* This function recursively generates a binary size report for every module listed in moduleLocations array.
*
* @param moduleLocations an array of strings where each is a path to location of a firebase module
*
*/
export async function generateReportForModules(
moduleLocations: string[]
): Promise<Report[]> {
const reportCollection: Report[] = [];
for (const moduleLocation of moduleLocations) {
// we traverse the dir in order to include binaries for submodules, e.g. @firebase/firestore/memory
// Currently we only traverse 1 level deep because we don't have any submodule deeper than that.
const reportsForModuleAndItsSubModule: Report[] = await traverseDirs(
moduleLocation,
generateReportForModule,
0,
1
);
if (
reportsForModuleAndItsSubModule !== null &&
reportsForModuleAndItsSubModule.length !== 0
) {
reportCollection.push(...reportsForModuleAndItsSubModule);
}
}
return reportCollection;
}