diff --git a/size-analysis-web-app/.firebase/hosting.YnVpbGQ.cache b/size-analysis-web-app/.firebase/hosting.YnVpbGQ.cache new file mode 100644 index 0000000000..25486a8c91 --- /dev/null +++ b/size-analysis-web-app/.firebase/hosting.YnVpbGQ.cache @@ -0,0 +1,20 @@ +asset-manifest.json,1597695867446,b62e47368e80d8f6b5af1b89aee6f20465ed745a74cac63b5f75c29b145b1f5d +favicon.ico,1597695844243,12c77055696278694a8feb77a9421dd85f8af25a81d812822be112e4be628fcf +index.html,1597695867445,9c2404ab57480016e871d7114f5b1679bf2f9a21538e144d05e8140fea70131e +logo192.png,1597695844243,76c449ccb9cd117c2f2338f091b18f7050f3210e249b2228f5c81b23f34377cd +manifest.json,1597695844243,0958a5e0c831126100c8c2d06a6bbaa665a3900f21aaff4130238a6f5a113aa1 +precache-manifest.8be8c353765d3ee015674bff60a33aed.js,1597695867446,38627821cae90e2294a53916e20dfa8e344098fda7557c8bdf9df9b1520661b1 +robots.txt,1597695844243,2544ca049f223a42bff01f72ad930a5edba75bbb7199d0f8430a02ff5aca16ec +service-worker.js,1597695867446,ee9a2218dc7fe2124b612f76784a73d1edd6528ac6b83e5dc83635c7fce08274 +static/css/main.ea0fb7c9.chunk.css,1597695867447,e51401c9547b87ce3fbaf83bbfe1b822fbc321751b7546e790e50005780c95c6 +static/css/main.ea0fb7c9.chunk.css.map,1597695867471,5e59d33270e717cc5c02c87aaefb432c5c92d95c59ce15b46db6eea22141e2e9 +logo512.png,1597695844243,7779210d56c1f3741e2e487799fe3092def4fa6ac450a60532b807c3a8971205 +static/js/2.94ef462e.chunk.js.LICENSE.txt,1597695867471,1c049e9bb20f8f478d2b756df6bcd0b4af96bd7eaad98ad6997e3cc232f8e090 +static/js/main.69097467.chunk.js,1597695867447,bffb8ee388c593b3886d6d6311f7ddd40e2ee9a74d7b94dfd7347a76c095d280 +static/js/main.69097467.chunk.js.map,1597695867471,83c9aeab29aead0ee3ceb5a5b4d55b205a5f2640ff21971f2f63e6b2a5218dd7 +static/js/runtime-main.40404df8.js,1597695867471,183460dde878c23dcaeb2b6c8b82144cbd76b4b08d868e1333c126dbeba1aec2 +static/js/runtime-main.40404df8.js.map,1597695867471,bbe2460f9cf8de080d305e30bd1108fc0a16c141604b939c2c493dec91edc5d6 +static/css/2.af3c1da9.chunk.css,1597695867471,08e3f99de906623894933e54d892afee81679221b821c7255acd9bfe0737c529 +static/css/2.af3c1da9.chunk.css.map,1597695867471,a4e81b0b0da94803ded8d0c34f1eab5d88919ee7603a8830a9c5fe84c69fba55 +static/js/2.94ef462e.chunk.js,1597695867471,bef48ff7f02b623cd87fcc48f4fe242209aab90da0e165319f955a9ea024791c +static/js/2.94ef462e.chunk.js.map,1597695867471,bff3121fca70a74e1fe2e66dacc53147865f4c6b08778d1c602357707e97d7dd diff --git a/size-analysis-web-app/.firebaserc b/size-analysis-web-app/.firebaserc new file mode 100644 index 0000000000..416765ca48 --- /dev/null +++ b/size-analysis-web-app/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-size-analysis" + } +} diff --git a/size-analysis-web-app/.gitignore b/size-analysis-web-app/.gitignore new file mode 100644 index 0000000000..4d29575de8 --- /dev/null +++ b/size-analysis-web-app/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/size-analysis-web-app/README.md b/size-analysis-web-app/README.md new file mode 100644 index 0000000000..9d7de07681 --- /dev/null +++ b/size-analysis-web-app/README.md @@ -0,0 +1,5 @@ +# Size Analysis Web Application + +- Design Doc: go/modular-exports-size-analysis-web-app + +- Application is hosted at https://fir-sdk-size-analysis.web.app/ diff --git a/size-analysis-web-app/firebase.json b/size-analysis-web-app/firebase.json new file mode 100644 index 0000000000..af41b7553c --- /dev/null +++ b/size-analysis-web-app/firebase.json @@ -0,0 +1,35 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": { + "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", + "source": "functions" + }, + "hosting": { + "public": "build", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + "emulators": { + "functions": { + "port": 5001 + }, + "hosting": { + "port": 5000 + }, + "ui": { + "enabled": true + } + } +} diff --git a/size-analysis-web-app/firestore.indexes.json b/size-analysis-web-app/firestore.indexes.json new file mode 100644 index 0000000000..415027e5dd --- /dev/null +++ b/size-analysis-web-app/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/size-analysis-web-app/firestore.rules b/size-analysis-web-app/firestore.rules new file mode 100644 index 0000000000..c38e3ae32e --- /dev/null +++ b/size-analysis-web-app/firestore.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} \ No newline at end of file diff --git a/size-analysis-web-app/functions/.gitignore b/size-analysis-web-app/functions/.gitignore new file mode 100644 index 0000000000..7fbb8b4088 --- /dev/null +++ b/size-analysis-web-app/functions/.gitignore @@ -0,0 +1,8 @@ +## Compiled JavaScript files +**/*.js +**/*.js.map + +# Typescript v1 declaration files +typings/ + +node_modules/ \ No newline at end of file diff --git a/size-analysis-web-app/functions/package.json b/size-analysis-web-app/functions/package.json new file mode 100644 index 0000000000..2a2fe530b5 --- /dev/null +++ b/size-analysis-web-app/functions/package.json @@ -0,0 +1,35 @@ +{ + "name": "functions", + "scripts": { + "lint": "tslint --project tsconfig.json", + "build": "tsc", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "10" + }, + "main": "lib/index.js", + "dependencies": { + "cors": "^2.8.5", + "firebase-admin": "^8.10.0", + "firebase-functions": "^3.9.0", + "firebase-functions-test": "^0.2.0", + "gzip-size": "^5.1.1", + "rollup": "2.21.0", + "rollup-plugin-commonjs": "10.1.0", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-node-resolve": "5.2.0", + "rollup-plugin-typescript2": "0.27.0", + "terser": "4.8.0", + "tmp": "^0.2.1", + "@types/tmp": "0.2.0", + "tslint": "^5.12.0", + "typescript": "3.8.3" + }, + "private": true, + "devDependencies": {} +} diff --git a/size-analysis-web-app/functions/src/analysis-helper.js b/size-analysis-web-app/functions/src/analysis-helper.js new file mode 100644 index 0000000000..6e677a8418 --- /dev/null +++ b/size-analysis-web-app/functions/src/analysis-helper.js @@ -0,0 +1,805 @@ +import { fileSync } from 'tmp'; +import { resolve, dirname, basename } from 'path'; +import { + writeFileSync, + readFileSync, + unlinkSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync +} from 'fs'; +import { rollup } from 'rollup'; +import { minify } from 'terser'; +import { + createProgram, + forEachChild, + isFunctionDeclaration, + isClassDeclaration, + isVariableDeclaration, + isEnumDeclaration, + isVariableStatement, + isIdentifier, + isImportDeclaration, + isNamedImports, + isNamespaceImport, + isExportDeclaration, + isStringLiteral, + isNamedExports +} from 'typescript'; +import { resolve as resolveRollup } from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import { deepCopy } from '@firebase/util'; + +/** + * @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. + */ +const TYPINGS = 'typings'; +const BUNDLE = 'esm2017'; +/** + * This functions builds a simple JS app that only depends on the provided + * export. It then uses Rollup to gather all top-level classes and functions + * that that the export depends on. + * + * @param exportName The name of the export to verify + * @param jsBundle The file name of the source bundle that contains the export + * @return A list of dependencies for the given export + */ +async function extractDependencies(exportName, jsBundle, map) { + const { dependencies } = await extractDependenciesAndSize( + exportName, + jsBundle, + map + ); + return dependencies; +} +/** + * Helper for extractDependencies that extracts the dependencies and the size + * of the minified build. + */ +async function extractDependenciesAndSize(exportName, jsBundle, map) { + const input = fileSync().name + '.js'; + const externalDepsResolvedOutput = fileSync().name + '.js'; + const externalDepsNotResolvedOutput = fileSync().name + '.js'; + const exportStatement = `export { ${exportName} } from '${resolve( + jsBundle + )}';`; + writeFileSync(input, exportStatement); + // Run Rollup on the JavaScript above to produce a tree-shaken build + const externalDepsResolvedBundle = await rollup({ + input, + plugins: [ + resolveRollup({ + mainFields: ['esm2017', 'module', 'main'] + }), + commonjs() + ] + }); + await externalDepsResolvedBundle.write({ + file: externalDepsResolvedOutput, + format: 'es' + }); + const externalDepsNotResolvedBundle = await rollup({ + input, + external: id => id.startsWith('@firebase') // exclude all firebase dependencies + }); + await externalDepsNotResolvedBundle.write({ + file: externalDepsNotResolvedOutput, + format: 'es' + }); + const dependencies = extractDeclarations(externalDepsNotResolvedOutput, map); + const externals = extractExternalDependencies(externalDepsNotResolvedOutput); + dependencies.externals.push(...externals); + const externalDepsResolvedOutputContent = readFileSync( + externalDepsResolvedOutput, + 'utf-8' + ); + // Extract size of minified build + const externalDepsNotResolvedOutputContent = readFileSync( + externalDepsNotResolvedOutput, + 'utf-8' + ); + const externalDepsResolvedOutputContentMinimized = minify( + externalDepsResolvedOutputContent, + { + output: { + comments: false + }, + mangle: { toplevel: true }, + compress: false + } + ); + const externalDepsNotResolvedOutputContentMinimized = minify( + externalDepsNotResolvedOutputContent, + { + output: { + comments: false + }, + mangle: { toplevel: true }, + compress: false + } + ); + unlinkSync(input); + unlinkSync(externalDepsNotResolvedOutput); + unlinkSync(externalDepsResolvedOutput); + return { + dependencies, + sizeInBytes: Buffer.byteLength( + externalDepsNotResolvedOutputContentMinimized.code, + 'utf-8' + ), + sizeInBytesWithExternalDeps: Buffer.byteLength( + externalDepsResolvedOutputContentMinimized.code, + 'utf-8' + ) + }; +} +/** + * 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 '...'; + */ +function extractDeclarations(filePath, map) { + const program = createProgram([filePath], { allowJs: true }); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) { + throw new Error( + `${'Failed to parse js file!' /* FILE_PARSING_ERROR */} ${filePath}` + ); + } + let declarations = { + functions: [], + classes: [], + variables: [], + enums: [], + externals: [] + }; + const namespaceImportSet = 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 = new Map(); + const importSymbolCurrentNameToOriginalName = new Map(); // key: current name value: original name + const importModuleLocationToExportedSymbolsList = new Map(); // key: module location, value: a list of all exported symbols of the module + forEachChild(sourceFile, node => { + if (isFunctionDeclaration(node)) { + declarations.functions.push(node.name.text); + } else if (isClassDeclaration(node)) { + declarations.classes.push(node.name.text); + } else if (isVariableDeclaration(node)) { + declarations.variables.push(node.name.getText()); + } else if (isEnumDeclaration(node)) { + declarations.enums.push(node.name.escapedText.toString()); + } else if (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 (isIdentifier(variableDeclaration.name)) { + declarations.variables.push( + variableDeclaration.name.getText(sourceFile) + ); + } + // Binding Pattern Example: export const {a, b} = {a: 1, b: 1}; + else { + variableDeclaration.name.elements.forEach(node => { + declarations.variables.push(node.name.getText(sourceFile)); + }); + } + }); + } else if (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 && + isNamedImports(node.importClause.namedBindings) + ) { + node.importClause.namedBindings.elements.forEach(each => { + const symbolName = 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 && + isNamespaceImport(node.importClause.namedBindings) + ) { + const symbolName = node.importClause.namedBindings.name.getText( + sourceFile + ); + namespaceImportSet.add(symbolName); + // import a from '@firebase/dummy-exp' + } else if ( + node.importClause.name && + isIdentifier(node.importClause.name) + ) { + const symbolName = 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 (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 (isStringLiteral(node.moduleSpecifier)) { + const reExportsWithFromClause = handleExportStatementsWithFromClause( + checker, + node, + node.moduleSpecifier.getText(sourceFile) + ); + // concatenate re-exported MemberList with MemberList of the dts file + for (const key of Object.keys(declarations)) { + 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, node, moduleName) { + const symbol = checker.getSymbolAtLocation(node.moduleSpecifier); + let declarations = { + functions: [], + classes: [], + variables: [], + enums: [], + externals: [] + }; + 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 && 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, exportsFullList) { + if (node.exportClause && isNamedExports(node.exportClause)) { + const actualExports = []; + node.exportClause.elements.forEach(exportSpecifier => { + const reExportedSymbol = 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; 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, + importSymbolCurrentNameToModuleLocation, + importSymbolCurrentNameToOriginalName, + importModuleLocationToExportedSymbolsList, + namespaceImportSymbolSet, + parentDeclarations +) { + if (node.exportClause && 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 = importSymbolCurrentNameToModuleLocation.get( + exportSymbolOriginalName + ); + let reExportedSymbols = null; + 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)) { + parentDeclarations[key].push(...reExportedSymbols[key]); + } + } + // handles declare first then export + // declare const apps: Map; + // 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. + */ +function dedup(memberList) { + for (const key of Object.keys(memberList)) { + const set = new Set(memberList[key]); + memberList[key] = Array.from(set); + } + return memberList; +} +function mapSymbolToType(map, memberList) { + const newMemberList = { + functions: [], + classes: [], + variables: [], + enums: [], + externals: [] + }; + for (const key of Object.keys(memberList)) { + memberList[key].forEach(element => { + if (map.has(element)) { + newMemberList[map.get(element)].push(element); + } else { + newMemberList[key].push(element); + } + }); + } + return newMemberList; +} +function extractOriginalSymbolName(exportSpecifier) { + // 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, keep) { + for (const key of Object.keys(memberList)) { + memberList[key] = memberList[key].filter(each => keep.includes(each)); + } +} +function replaceAll(memberList, original, current) { + for (const key of Object.keys(memberList)) { + memberList[key] = replaceWith(memberList[key], original, current); + } +} +function replaceWith(arr, original, current) { + const rv = []; + for (const each of arr) { + if (each.localeCompare(original) === 0) { + rv.push(current); + } else { + rv.push(each); + } + } + return rv; +} +function isExportRenamed(exportSpecifier) { + return exportSpecifier.propertyName != null; +} +/** + * + * This functions writes generated json report(s) to a file + */ +function writeReportToFile(report, outputFile) { + if (existsSync(outputFile) && !lstatSync(outputFile).isFile()) { + throw new Error( + 'An output file is required but a directory given!' /* OUTPUT_FILE_REQUIRED */ + ); + } + const directoryPath = dirname(outputFile); + //for output file path like ./dir/dir1/dir2/file, we need to make sure parent dirs exist. + if (!existsSync(directoryPath)) { + mkdirSync(directoryPath, { recursive: true }); + } + writeFileSync(outputFile, report); +} +/** + * + * This functions writes generated json report(s) to a file of given directory + */ +function writeReportToDirectory(report, fileName, directoryPath) { + if (existsSync(directoryPath) && !lstatSync(directoryPath).isDirectory()) { + throw new Error( + 'An output directory is required but a file given!' /* OUTPUT_DIRECTORY_REQUIRED */ + ); + } + writeReportToFile(report, `${directoryPath}/${fileName}`); +} +/** + * This function extract unresolved external module symbols from bundle file import statements. + * + */ +function extractExternalDependencies(minimizedBundleFile) { + const program = createProgram([minimizedBundleFile], { allowJs: true }); + const sourceFile = program.getSourceFile(minimizedBundleFile); + if (!sourceFile) { + throw new Error( + `${ + 'Failed to parse js file!' /* FILE_PARSING_ERROR */ + } ${minimizedBundleFile}` + ); + } + const externalsMap = new Map(); + forEachChild(sourceFile, node => { + if (isImportDeclaration(node) && node.importClause) { + const moduleName = 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 && + 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 && + isNamespaceImport(node.importClause.namedBindings) + ) { + externalsMap.get(moduleName).push('*'); + // import a from '@firebase/dummy-exp' + } else if ( + node.importClause.name && + isIdentifier(node.importClause.name) + ) { + externalsMap.get(moduleName).push('default export'); + } + } + }); + const externals = []; + externalsMap.forEach((value, key) => { + const external = { + moduleName: key, + symbols: value + }; + externals.push(external); + }); + 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 + * @param outputDirectory a path to a directory where the reports will be written under. + * @param writeFiles when true, will write reports to designated directory specified by outputDirectory. + */ +async function generateReportForModule( + moduleLocation, + outputDirectory, + writeFiles +) { + const packageJsonPath = `${moduleLocation}/package.json`; + if (!existsSync(packageJsonPath)) { + return; + } + const packageJson = require(packageJsonPath); + // to exclude -types modules + if (packageJson[TYPINGS]) { + const dtsFile = `${moduleLocation}/${packageJson[TYPINGS]}`; + if (!packageJson[BUNDLE]) { + throw new Error( + 'Module does not have a bundle file!' /* BUNDLE_FILE_DOES_NOT_EXIST */ + ); + } + const bundleFile = `${moduleLocation}/${packageJson[BUNDLE]}`; + const json = await generateReport(dtsFile, bundleFile); + const fileName = `${basename(packageJson.name)}-dependency.json`; + if (writeFiles) { + writeReportToDirectory(json, fileName, resolve(outputDirectory)); + } + return json; + } +} +/** + * + * This function creates a map from a MemberList object which maps symbol names (key) listed + * to its type (value) + */ +function buildMap(api) { + const map = new Map(); + for (const type of Object.keys(api)) { + api[type].forEach(element => { + map.set(element, type); + }); + } + return map; +} +/** + * A recursive function that locates and generates reports for sub-modules + */ +function traverseDirs( + moduleLocation, + outputDirectory, + writeFiles, + executor, + level, + levelLimit +) { + if (level > levelLimit) { + return; + } + executor(moduleLocation, outputDirectory, writeFiles); + for (const name of readdirSync(moduleLocation)) { + const p = `${moduleLocation}/${name}`; + if (lstatSync(p).isDirectory()) { + traverseDirs( + p, + outputDirectory, + writeFiles, + executor, + level + 1, + levelLimit + ); + } + } +} +/** + * + * 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. + */ +async function buildJsonReport(publicApi, jsFile, map) { + const result = {}; + for (const exp of publicApi.classes) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + for (const exp of publicApi.functions) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + for (const exp of publicApi.variables) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + for (const exp of publicApi.enums) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + return JSON.stringify(result, null, 4); +} +async function generateReport(dtsFile, bundleFile) { + const resolvedDtsFile = resolve(dtsFile); + const resolvedBundleFile = resolve(bundleFile); + if (!existsSync(resolvedDtsFile)) { + throw new Error( + 'Input dts file does not exist!' /* INPUT_DTS_FILE_DOES_NOT_EXIST */ + ); + } + if (!existsSync(resolvedBundleFile)) { + throw new Error( + 'Input bundle file does not exist!' /* INPUT_BUNDLE_FILE_DOES_NOT_EXIST */ + ); + } + const publicAPI = extractDeclarations(resolvedDtsFile); + const map = buildMap(publicAPI); + return buildJsonReport(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 + * @param outputDirectory a path to a directory where the reports will be written under. + * @param writeFiles when true, will write reports to designated directory specified by outputDirectory. + * + * + */ +function generateReportForModules( + moduleLocations, + outputDirectory, + writeFiles +) { + 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. + traverseDirs( + moduleLocation, + outputDirectory, + writeFiles, + generateReportForModule, + 0, + 1 + ); + } +} + +export { + buildJsonReport, + buildMap, + dedup, + extractDeclarations, + extractDependencies, + extractDependenciesAndSize, + extractExternalDependencies, + generateReport, + generateReportForModule, + generateReportForModules, + mapSymbolToType, + replaceAll, + writeReportToDirectory, + writeReportToFile +}; diff --git a/size-analysis-web-app/functions/src/functions-helper.ts b/size-analysis-web-app/functions/src/functions-helper.ts new file mode 100644 index 0000000000..df0b2efeca --- /dev/null +++ b/size-analysis-web-app/functions/src/functions-helper.ts @@ -0,0 +1,279 @@ +/** + * @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 { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as tmp from 'tmp'; +import { resolve as pathResolve, basename } from 'path'; +import { rollup } from 'rollup'; +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import { minify, MinifyOutput } from 'terser'; +import { extractDeclarations } from './analysis-helper'; +import { sync } from 'gzip-size'; +export const pkgName: string = 'firebase'; +const userSelectedSymbolFile = 'selected-symbols.js'; +export const userSelectedSymbolsBundleFile: string = + 'selected-symbols-bundle.js'; +export const packageInstalledDirectory: string = tmp.dirSync().name; +export const pkgRoot: string = `${packageInstalledDirectory}/node_modules/${pkgName}`; + +/** Contains a list of symbols by type. */ +export interface Symbols { + classes: string[]; + functions: string[]; + variables: string[]; + enums: string[]; +} + +export interface Module { + moduleName: string; + symbols: Symbols; +} + +/** + * Abstraction of the final report generated given user selected symbols + */ +export interface Report { + dependencies: Symbols; + size: number; + sizeAfterGzip: number; +} +/** + * + * This function extracts all symbols exported by the module. + * + */ +export async function generateExportedSymbolsListForModule( + moduleLocation: string +): Promise { + const packageJsonPath = `${moduleLocation}/package.json`; + if (!fs.existsSync(packageJsonPath)) { + return null as any; + } + const packageJson = require(packageJsonPath); + // to exclude -types modules + if (packageJson.typings) { + const dtsFile = `${moduleLocation}/${packageJson.typings}`; + const exportedSymbolsList: Symbols = extractDeclarations(dtsFile, null); + const module: Module = { + moduleName: packageJson.name, + symbols: exportedSymbolsList + }; + return module; + } + return null as any; +} +/** + * A recursive function that locates and generates exported symbol lists for the module and its sub-modules + */ +export async function traverseDirs( + moduleLocation: string, + executor: Function, + level: number, + levelLimit: number +): Promise { + if (level > levelLimit) { + return null as any; + } + + const modules: Module[] = []; + const module: Module = await executor(moduleLocation); + if (module !== null) { + modules.push(module); + } + + for (const name of fs.readdirSync(moduleLocation)) { + const p = `${moduleLocation}/${name}`; + + if (fs.lstatSync(p).isDirectory()) { + const subModules: Module[] = await traverseDirs( + p, + executor, + level + 1, + levelLimit + ); + if (subModules !== null && subModules.length !== 0) { + modules.push(...subModules); + } + } + } + return modules; +} + +/** + * + * This function extracts exported symbols from every module of the given list. + * + */ +export async function generateExportedSymbolsListForModules( + moduleLocations: string[] +): Promise { + const modules: Module[] = []; + + 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 moduleAndSubModules: Module[] = await traverseDirs( + moduleLocation, + generateExportedSymbolsListForModule, + 0, + 1 + ); + if (moduleAndSubModules !== null && moduleAndSubModules.length !== 0) { + modules.push(...moduleAndSubModules); + } + } + return modules; +} + +/** + * This functions creates a package.json file programatically and installs the firebase package. + */ +export function setUpPackageEnvironment( + firebaseVersionToBeInstalled: string +): void { + try { + const packageJsonContent: string = `{\"name\":\"size-analysis-firebase\",\"version\":\"0.1.0\",\"dependencies\":{\"typescript\":\"3.8.3\",\"${pkgName}\":\"${firebaseVersionToBeInstalled}\"}}`; + fs.writeFileSync( + `${packageInstalledDirectory}/package.json`, + packageJsonContent + ); + execSync(`cd ${packageInstalledDirectory}; npm install; cd ..`); + } catch (error) { + throw new Error(error); + } +} + +/** + * This function generates a bundle file from the given export statements. + * @param userSelectedSymbolsFileContent JavaScript export statements that consume user selected symbols + * @returns the generate bundle file content + */ +export async function generateBundleFileGivenCustomJsFile( + userSelectedSymbolsJsFileContent: string +): Promise { + try { + const absolutePathToUserSelectedSymbolsBundleFile = pathResolve( + `${packageInstalledDirectory}/${userSelectedSymbolsBundleFile}` + ); + const absolutePathToUserSelectedSymbolsJsFile = pathResolve( + `${packageInstalledDirectory}/${userSelectedSymbolFile}` + ); + fs.writeFileSync( + absolutePathToUserSelectedSymbolsJsFile, + userSelectedSymbolsJsFileContent + ); + + const bundle = await rollup({ + input: absolutePathToUserSelectedSymbolsJsFile, + plugins: [ + resolve({ + mainFields: ['esm2017', 'module', 'main'] + }), + commonjs() + ] + }); + + const { output } = await bundle.generate({ format: 'es' }); + await bundle.write({ + file: absolutePathToUserSelectedSymbolsBundleFile, + format: 'es' + }); + return output[0].code; + } catch (error) { + throw new Error(error); + } +} + +/** + * This function calculates both the binary size of the original bundle file and the size of the gzippzed version. + * The calculation is done after code minimization of the bundle file. + * @param bundleFileContent the content/code of the js bundle file + * + */ +export function calculateBinarySizeGivenBundleFile( + bundleFileContent: string +): number[] { + const bundleFileContentMinified: MinifyOutput = minify(bundleFileContent, { + output: { + comments: false + }, + mangle: true, + compress: false + }); + + return [ + Buffer.byteLength(bundleFileContentMinified.code!, 'utf-8'), + sync(bundleFileContentMinified.code!) + ]; +} + +/** + * This function programatically creates JS export statements for symbols listed in userSelectedSymbols + * + */ +export function buildJsFileGivenUserSelectedSymbols( + userSelectedSymbols: Module[] +): string { + const statements: string[] = []; + for (const userSelectedSymbol of userSelectedSymbols) { + let statement = ''; + const selectedSymbols: Symbols = userSelectedSymbol.symbols; + for (const selectedSymbolsOfType of Object.values(selectedSymbols)) { + if ( + Array.isArray(selectedSymbolsOfType) && + selectedSymbolsOfType.length > 0 + ) { + selectedSymbolsOfType.forEach(selectedSymbolOfType => { + statement += `${selectedSymbolOfType}, `; + }); + } else { + continue; + } + } + if (statement.length == 0) { + continue; + } + statement = 'export { ' + statement; + statement = statement.trimRight(); + statement = statement.substring(0, statement.length - 1); + statement += `} from \'@firebase/${basename( + userSelectedSymbol.moduleName + )}\';`; + statements.push(statement); + } + + return statements.join('\n'); +} +/** + * This functions returns a list of module(under firebase scope) locations. + */ +export function retrieveAllModuleLocation(): string[] { + const moduleLocations: string[] = []; + try { + const pkgRootAbsolutedPath: string = pathResolve(`${pkgRoot}`); + const pkgJson = require(`${pkgRootAbsolutedPath}/package.json`); + const components = pkgJson.components; + for (const component of components) { + moduleLocations.push(`${pkgRootAbsolutedPath}/${component}`); + } + return moduleLocations; + } catch (error) { + throw new Error(error); + } +} diff --git a/size-analysis-web-app/functions/src/index.ts b/size-analysis-web-app/functions/src/index.ts new file mode 100644 index 0000000000..22d789ad55 --- /dev/null +++ b/size-analysis-web-app/functions/src/index.ts @@ -0,0 +1,173 @@ +/** + * @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 functions from 'firebase-functions'; +import { execSync } from 'child_process'; +import { resolve } from 'path'; + +import { + packageInstalledDirectory, + userSelectedSymbolsBundleFile, + Report, + retrieveAllModuleLocation, + buildJsFileGivenUserSelectedSymbols, + calculateBinarySizeGivenBundleFile, + setUpPackageEnvironment, + generateExportedSymbolsListForModules, + generateBundleFileGivenCustomJsFile +} from './functions-helper'; + +import { extractDeclarations } from './analysis-helper'; +let cors = require('cors')({ origin: true }); +const versionFilter = new RegExp(/^\d+.\d*.\d+$/); + +export const helloWorld = functions.https.onRequest((request, response) => { + response.send('Hello from Firebase!'); +}); +export const retrieveFirebaseVersionFromNPM = functions.https.onRequest( + (request, response) => { + if (request.method !== 'GET') { + response.status(405).end(); + return; + } + cors(request, response, () => { + try { + const firebaseName: string = 'firebase'; + // execute shell npm command to retrieve published versions of firebase + const versionArrayString = execSync(`npm view ${firebaseName} versions`) + .toString() + .replace(/'/g, '"'); + // convert string representation of array to actual array + let versionsArray: string[] = JSON.parse(versionArrayString); + // keep versions that are of major.minor.patch format + versionsArray = versionsArray.filter(each => versionFilter.test(each)); + versionsArray = versionsArray.reverse(); + response.set({ + 'Content-Type': 'application/json' + }); + response.status(200).send(versionsArray); + } catch (error) { + response.status(500).send(error); + } + }); + } +); + +export const downloadPackageFromNPMGivenVersionAndReturnExportedSymbols = functions.https.onRequest( + (request, response) => { + if (request.method !== 'POST' && request.method !== 'OPTIONS') { + response.status(405).end(); + return; + } + + cors(request, response, () => { + if ( + !request.get('Content-Type') || + request.get('Content-Type')!.localeCompare('application/json') !== 0 + ) { + // 415 Unsupported Media Type + response + .status(415) + .send('request body requires application/json type'); + return; + } + if (!request.body.version) { + // 422 Unprocessable Entity + response.status(422).send('request body missing field: version'); + return; + } + const versionTobeInstalled = request.body.version; + try { + setUpPackageEnvironment(versionTobeInstalled); + const allModuleLocations: string[] = retrieveAllModuleLocation(); + generateExportedSymbolsListForModules(allModuleLocations) + .then(exportedSymbolsListForModules => { + response.set({ + 'Content-Type': 'application/json' + }); + response.status(200).send(exportedSymbolsListForModules); + }) + .catch(error => { + response.status(500).send(error); + }); + } catch (error) { + response.status(500).send(error); + } + }); + } +); + +export const generateSizeAnalysisReportGivenCustomBundle = functions.https.onRequest( + (request, response) => { + if (request.method !== 'POST' && request.method !== 'OPTIONS') { + response.status(405).end(); + return; + } + cors(request, response, () => { + if ( + !request.get('Content-Type') || + request.get('Content-Type')!.localeCompare('application/json') !== 0 + ) { + // 415 Unsupported Media Type + response + .status(415) + .send('request body requires application/json type'); + return; + } + if (!request.body.version) { + response.status(422).send('request body missing field: version'); + return; + } + if (!request.body.symbols) { + response.status(422).send('request body missing field: symbols'); + return; + } + try { + const versionTobeInstalled: string = request.body.version; + + const userSelectedSymbolsFileContent = buildJsFileGivenUserSelectedSymbols( + request.body.symbols + ); + setUpPackageEnvironment(versionTobeInstalled); + + generateBundleFileGivenCustomJsFile(userSelectedSymbolsFileContent) + .then(customBundleFileContent => { + const sizeArray: number[] = calculateBinarySizeGivenBundleFile( + customBundleFileContent + ); + const customBundleFilePath = resolve( + `${packageInstalledDirectory}/${userSelectedSymbolsBundleFile}` + ); + const report: Report = { + dependencies: extractDeclarations(customBundleFilePath, null), + size: sizeArray[0], + sizeAfterGzip: sizeArray[1] + }; + response.set({ + 'Content-Type': 'application/json' + }); + response.status(200).send(report); + }) + .catch(error => { + response.status(500).send(error); + }); + } catch (error) { + response.status(500).send(error); + } + }); + } +); diff --git a/size-analysis-web-app/functions/tsconfig.json b/size-analysis-web-app/functions/tsconfig.json new file mode 100644 index 0000000000..feaeaa4021 --- /dev/null +++ b/size-analysis-web-app/functions/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017", + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "compileOnSave": true, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/size-analysis-web-app/functions/tslint.json b/size-analysis-web-app/functions/tslint.json new file mode 100644 index 0000000000..98b2bfdc73 --- /dev/null +++ b/size-analysis-web-app/functions/tslint.json @@ -0,0 +1,115 @@ +{ + "rules": { + // -- Strict errors -- + // These lint rules are likely always a good idea. + + // Force function overloads to be declared together. This ensures readers understand APIs. + "adjacent-overload-signatures": true, + + // Do not allow the subtle/obscure comma operator. + "ban-comma-operator": true, + + // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. + "no-namespace": true, + + // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. + "no-parameter-reassignment": true, + + // Force the use of ES6-style imports instead of /// imports. + "no-reference": true, + + // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the + // code currently being edited (they may be incorrectly handling a different type case that does not exist). + "no-unnecessary-type-assertion": true, + + // Disallow nonsensical label usage. + "label-position": true, + + // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. + "no-conditional-assignment": true, + + // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). + "no-construct": true, + + // Do not allow super() to be called twice in a constructor. + "no-duplicate-super": true, + + // Do not allow the same case to appear more than once in a switch block. + "no-duplicate-switch-case": true, + + // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this + // rule. + "no-duplicate-variable": [true, "check-parameters"], + + // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should + // instead use a separate variable name. + "no-shadowed-variable": true, + + // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. + "no-empty": [true, "allow-empty-catch"], + + // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. + // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. + "no-floating-promises": true, + + // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when + // deployed. + "no-implicit-dependencies": true, + + // The 'this' keyword can only be used inside of classes. + "no-invalid-this": true, + + // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. + "no-string-throw": true, + + // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. + "no-unsafe-finally": true, + + // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); + "no-void-expression": [true, "ignore-arrow-function-shorthand"], + + // Disallow duplicate imports in the same file. + "no-duplicate-imports": true, + + + // -- Strong Warnings -- + // These rules should almost never be needed, but may be included due to legacy code. + // They are left as a warning to avoid frustration with blocked deploys when the developer + // understand the warning and wants to deploy anyway. + + // Warn when an empty interface is defined. These are generally not useful. + "no-empty-interface": {"severity": "warning"}, + + // Warn when an import will have side effects. + "no-import-side-effect": {"severity": "warning"}, + + // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for + // most values and let for values that will change. + "no-var-keyword": {"severity": "warning"}, + + // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. + "triple-equals": {"severity": "warning"}, + + // Warn when using deprecated APIs. + "deprecation": {"severity": "warning"}, + + // -- Light Warnings -- + // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" + // if TSLint supported such a level. + + // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. + // (Even better: check out utils like .map if transforming an array!) + "prefer-for-of": {"severity": "warning"}, + + // Warns if function overloads could be unified into a single function with optional or rest parameters. + "unified-signatures": {"severity": "warning"}, + + // Prefer const for values that will not change. This better documents code. + "prefer-const": {"severity": "warning"}, + + // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. + "trailing-comma": {"severity": "warning"} + }, + + "defaultSeverity": "error" +} diff --git a/size-analysis-web-app/generate-react-cli.json b/size-analysis-web-app/generate-react-cli.json new file mode 100644 index 0000000000..57b676a16f --- /dev/null +++ b/size-analysis-web-app/generate-react-cli.json @@ -0,0 +1,15 @@ +{ + "usesTypeScript": true, + "usesCssModule": true, + "cssPreprocessor": "css", + "testLibrary": "None", + "component": { + "default": { + "path": "src/components", + "withStyle": true, + "withTest": true, + "withStory": false, + "withLazy": true + } + } +} \ No newline at end of file diff --git a/size-analysis-web-app/package.json b/size-analysis-web-app/package.json new file mode 100644 index 0000000000..3d178ab54b --- /dev/null +++ b/size-analysis-web-app/package.json @@ -0,0 +1,43 @@ +{ + "name": "modular-size-analysis", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/react": "^16.9.43", + "bootstrap": "^4.5.0", + "chart.js": "^2.9.3", + "jquery": "^3.5.1", + "popper.js": "^1.16.1", + "react": "^16.13.1", + "react-chartjs-2": "^2.10.0", + "react-dom": "^16.13.1", + "react-json-view": "^1.19.1", + "react-router-dom": "^5.2.0", + "react-scripts": "3.4.1", + "typescript": "^3.9.6" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/size-analysis-web-app/public/favicon.ico b/size-analysis-web-app/public/favicon.ico new file mode 100644 index 0000000000..bcd5dfd67c Binary files /dev/null and b/size-analysis-web-app/public/favicon.ico differ diff --git a/size-analysis-web-app/public/index.html b/size-analysis-web-app/public/index.html new file mode 100644 index 0000000000..aa069f27cb --- /dev/null +++ b/size-analysis-web-app/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/size-analysis-web-app/public/logo192.png b/size-analysis-web-app/public/logo192.png new file mode 100644 index 0000000000..fc44b0a379 Binary files /dev/null and b/size-analysis-web-app/public/logo192.png differ diff --git a/size-analysis-web-app/public/logo512.png b/size-analysis-web-app/public/logo512.png new file mode 100644 index 0000000000..a4e47a6545 Binary files /dev/null and b/size-analysis-web-app/public/logo512.png differ diff --git a/size-analysis-web-app/public/manifest.json b/size-analysis-web-app/public/manifest.json new file mode 100644 index 0000000000..080d6c77ac --- /dev/null +++ b/size-analysis-web-app/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/size-analysis-web-app/public/robots.txt b/size-analysis-web-app/public/robots.txt new file mode 100644 index 0000000000..e9e57dc4d4 --- /dev/null +++ b/size-analysis-web-app/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/size-analysis-web-app/src/App.css b/size-analysis-web-app/src/App.css new file mode 100644 index 0000000000..2eb3d1fcee --- /dev/null +++ b/size-analysis-web-app/src/App.css @@ -0,0 +1,53 @@ +.text { + color: #FFFFFF; + font-family: Verdana, Geneva, Tahoma, sans-serif; +} + +.list-item-module { + background-color: #FFCB2B; + +} +.list-item-function { + background-color: #FF8964; +} +.code-pad { + background-color: #FFFFFF; + font-family: 'Courier New', Courier, monospace; + height: 40vh; + overflow: scroll; +} +.visualization { + height: 45vh; + overflow: scroll; + border: 2px solid white; +} + +.bundle-overview { + background-color: #FFFFFF; + height: 48vh; + overflow: scroll; + + +} + +.bundle-item { + height: 80%; + + overflow: inherit; +} +.light-orange-btn { + background-color: #FF8964; + border-color: #FF8964; +} +.yellow-btn { + background-color:#FFCB2B ; + border-color: #FFCB2B; +} +.wrapper { + min-height: 100vh; + background-color: #059BE5; +} +.preview { + max-height: 90vh; + overflow: scroll; +} \ No newline at end of file diff --git a/size-analysis-web-app/src/App.jsx b/size-analysis-web-app/src/App.jsx new file mode 100644 index 0000000000..b1bf05f542 --- /dev/null +++ b/size-analysis-web-app/src/App.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; +import Visualization from './components/Visualization'; +import BundleCreation from './components/BundleCreation'; +export default function App() { + + + return ( + + + + + + + ); +} diff --git a/size-analysis-web-app/src/App.test.js b/size-analysis-web-app/src/App.test.js new file mode 100644 index 0000000000..65d3d06b6c --- /dev/null +++ b/size-analysis-web-app/src/App.test.js @@ -0,0 +1,26 @@ +/** + * @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 React from 'react'; +import { render } from '@testing-library/react'; +import App from './components/BundleCreation'; + +test('renders learn react link', () => { + const { getByText } = render(); + const linkElement = getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/size-analysis-web-app/src/FirebaseConfig.jsx b/size-analysis-web-app/src/FirebaseConfig.jsx new file mode 100644 index 0000000000..9faba85523 --- /dev/null +++ b/size-analysis-web-app/src/FirebaseConfig.jsx @@ -0,0 +1,12 @@ +import firebase from '@firebase/app'; +import 'firebase/functions'; +export const firebaseConfig = { + apiKey: "AIzaSyCEcME7qRy5IdImugwLzhCthCJseaAg1Vw", + authDomain: "fir-sdk-size-analysis.firebaseapp.com", + databaseURL: "https://fir-sdk-size-analysis.firebaseio.com", + projectId: "fir-sdk-size-analysis", + storageBucket: "fir-sdk-size-analysis.appspot.com", + messagingSenderId: "737695495424", + appId: "1:737695495424:web:de885c9f4edf7e2b912fe5" +}; +const fire = firebase.initializeApp(firebaseConfig); \ No newline at end of file diff --git a/size-analysis-web-app/src/components/Alert.jsx b/size-analysis-web-app/src/components/Alert.jsx new file mode 100644 index 0000000000..b5eaf2870d --- /dev/null +++ b/size-analysis-web-app/src/components/Alert.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export default function Alert(props) { + + + return ( +
+ {props.errorMessage} + +
+ ); +} \ No newline at end of file diff --git a/size-analysis-web-app/src/components/BundleCreation.jsx b/size-analysis-web-app/src/components/BundleCreation.jsx new file mode 100644 index 0000000000..e701689dd2 --- /dev/null +++ b/size-analysis-web-app/src/components/BundleCreation.jsx @@ -0,0 +1,324 @@ +import React, { Component } from 'react'; +import ReactJson from 'react-json-view' +import DropDown from './DropDown'; +import Module from './Module'; +import BundlePanel from './BundlePanel'; +import { SECTION, ENDPOINTS, API_ROOT, TEXT } from '../constants'; +import '../App.css'; + + +class BundleCreation extends Component { + constructor(props) { + super(props); + this.state = { + selectedVersion: "", + allModulesOfSelectedVersion: [], + currentBundle: new Map(), + currentBundleReport: null, + isCurrentBundleReportValid: false, + dropDownData: [], + isDropDownLoaded: false, + areModulesLoaded: true, + isBundleOverviewLoaded: true + } + this.handleChange = this.handleChange.bind(this); + this.onFirebaseVersionSelected = this.onFirebaseVersionSelected.bind(this); + this.handleAddModuleToBundle = this.handleAddModuleToBundle.bind(this); + this.handleAddSymbolToBundle = this.handleAddSymbolToBundle.bind(this); + this.handleUpdateBundle = this.handleUpdateBundle.bind(this); + this.handleOnCalculateBundle = this.handleOnCalculateBundle.bind(this); + this.handleRemoveModuleFromBundle = this.handleRemoveModuleFromBundle.bind(this); + this.handleRemoveSymbolFromBundle = this.handleRemoveSymbolFromBundle.bind(this); + this.populateDropDownData = this.populateDropDownData.bind(this); + this.extractAllUserSelectedSymbolsFromCurrentModule = this.extractAllUserSelectedSymbolsFromCurrentModule.bind(this); + + } + + handleUpdateBundle(updatedBundle) { + this.setState({ + isCurrentBundleReportValid: false, + currentBundle: updatedBundle + }) + } + handleRemoveModuleFromBundle(moduleNameTobeRemoved) { + let tmpCurrentBundle = new Map(this.state.currentBundle); + tmpCurrentBundle.delete(moduleNameTobeRemoved); + this.handleUpdateBundle(tmpCurrentBundle); + } + handleRemoveSymbolFromBundle(symbolNameTobeRemoved, moduleName) { + let tmpCurrentBundle = new Map(this.state.currentBundle); + tmpCurrentBundle.get(moduleName).delete(symbolNameTobeRemoved); + if (tmpCurrentBundle.get(moduleName).size === 0) { + tmpCurrentBundle.delete(moduleName); + } + this.handleUpdateBundle(tmpCurrentBundle); + + } + extractAllUserSelectedSymbolsFromCurrentModule(moduleName, userSelectedSymbols) { + let module = this.state.allModulesOfSelectedVersion.filter(module => module.moduleName.localeCompare(moduleName) === 0); + + const symbols = { + functions: [], + classes: [], + enums: [], + variables: [] + }; + + if (module.length === 0) return symbols; + module = module[0]; + if (userSelectedSymbols.size === 0) return module.symbols; + Object.keys(module.symbols).forEach(type => { + symbols[type] = module.symbols[type].filter(symbol => userSelectedSymbols.has(symbol)); + + }); + return symbols; + } + + handleAddModuleToBundle(moduleName) { + // if adding a whole module to the bundle, then an entry in map with key module name and value an empty set + // if adding some functions of a module to the bundle, then an entry in map with key module name and value a set of function names + let tmpCurrentBundle = new Map(this.state.currentBundle); + if (!tmpCurrentBundle.has(moduleName)) { + tmpCurrentBundle.set(moduleName, new Set()); + } + + else { + tmpCurrentBundle.get(moduleName).clear(); + } + this.handleUpdateBundle(tmpCurrentBundle); + + + } + handleAddSymbolToBundle(symbolName, moduleName) { + let tmpCurrentBundle = new Map(this.state.currentBundle); + if (!tmpCurrentBundle.has(moduleName)) { + tmpCurrentBundle.set(moduleName, new Set()); + } + tmpCurrentBundle.get(moduleName).add(symbolName); + + this.handleUpdateBundle(tmpCurrentBundle); + + } + handleChange(e) { + + this.setState({ + [e.target.name]: e.target.value + }); + } + componentDidMount() { + this.populateDropDownData(); + } + + populateDropDownData() { + fetch(`${API_ROOT}${ENDPOINTS.retrieveFirebaseVersionFromNPM}`, { + method: "GET", + headers: { + 'Accept': 'application/json' + }, + + }) + .then(res => res.json()) + .then( + (result) => { + this.setState(prevState => ({ + isDropDownLoaded: true, + dropDownData: [...prevState.dropDownData, ...result] + })) + }, + (error) => { + console.log(error); + } + ); + + + } + handleOnCalculateBundle() { + this.setState({ + isBundleOverviewLoaded: false, + currentBundleReport: null + }); + const requestBodySymbolsField = []; + + for (const moduleName of this.state.currentBundle.keys()) { + + requestBodySymbolsField.push({ + moduleName: moduleName, + symbols: this.extractAllUserSelectedSymbolsFromCurrentModule(moduleName, this.state.currentBundle.get(moduleName)) + }); + } + + const requestBody = { + version: this.state.selectedVersion, + symbols: requestBodySymbolsField + }; + fetch(`${API_ROOT}${ENDPOINTS.generateSizeAnalysisReportGivenCustomBundle}`, { + method: "POST", + headers: { + 'content-type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(requestBody), + }) + .then(res => res.json()) + .then( + (report) => { + this.setState({ + isBundleOverviewLoaded: true, + isCurrentBundleReportValid: true, + currentBundleReport: report + }); + }, + (error) => { + console.log(error); + } + ); + } + onFirebaseVersionSelected(e) { + + // retrieve the packages and get all the functions + this.setState({ + [e.target.name]: e.target.value, + areModulesLoaded: false + }); + const requestBody = { version: e.target.value }; + fetch(`${API_ROOT}${ENDPOINTS.downloadPackageFromNPMGivenVersionAndReturnExportedSymbols}`, { + method: "POST", + headers: { + 'content-type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(requestBody), + }) + .then(res => res.json()) + .then( + (modules) => { + this.setState({ + areModulesLoaded: true, + allModulesOfSelectedVersion: modules + }); + }, + (error) => { + console.log(error); + } + ); + } + render() { + const style = { + + "height": "inherit", + + "overflow": "scroll" + } + return ( +
+
+
+

{SECTION.bundleCreation}

+ +
+
+ { + this.state.isDropDownLoaded ? + + : + +
+
+
+
+ } +
+
+
+
+ + { + this.state.areModulesLoaded ? + + this.state.allModulesOfSelectedVersion.map(module => + ) : + +
+
+
+
+ } + +
+ +
+
+ +
+ +
+
+ {this.state.currentBundleReport ? +
+
+
+

{TEXT.sizePrompt}: {this.state.currentBundleReport.size} {TEXT.unit}

+
+
+

{TEXT.sizeAfterGzipPrompt}: {this.state.currentBundleReport.sizeAfterGzip} {TEXT.unit}

+
+
+ {this.state.isBundleOverviewLoaded && !this.state.isCurrentBundleReportValid ? {TEXT.outdatedBadgeText} : null} +
+
+ + + +
+ : this.state.isBundleOverviewLoaded ? +

{SECTION.bundleOverview}

: + +
+
+
+
+ + } +
+
+ + +
+ +
+ + + + +
+ + ); + + } +} +export default BundleCreation; diff --git a/size-analysis-web-app/src/components/BundleItem.jsx b/size-analysis-web-app/src/components/BundleItem.jsx new file mode 100644 index 0000000000..c7f89c2d6e --- /dev/null +++ b/size-analysis-web-app/src/components/BundleItem.jsx @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import { TEXT } from '../constants'; + +class BundleItem extends Component { + constructor(props) { + super(props); + this.state = { + moduleName: this.props.moduleName, + symbols: this.props.symbolName + + } + this.composeImportStatement = this.composeImportStatement.bind(this); + } + componentDidMount() { + + } + componentDidUpdate(prevProps) { + if (prevProps.symbolName !== this.props.symbolName) { + this.setState({ symbols: this.props.symbolName }); + } + if (prevProps.moduleName !== this.props.moduleName) { + this.setState({ moduleName: this.props.moduleName }); + } + + } + composeImportStatement(symbolNames, moduleName) { + if (symbolNames.size === 0) { + return

import {TEXT.moduleRepresentation} from {moduleName}

; + } else { + return ( +
+

import {'{'}

+
+ {Array.from(symbolNames).map(symbolName => +
+ { this.props.handleRemoveSymbolFromBundle(symbolName, moduleName) }}> + {symbolName} + + {' '} +
+ )} + +
+

{'}'} from {moduleName};

+
+ ); + } + } + + render() { + return ( +
  • + {this.composeImportStatement(this.props.symbolName, this.props.moduleName)} + + +
  • + ); + } +} + +export default BundleItem; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/BundlePanel.jsx b/size-analysis-web-app/src/components/BundlePanel.jsx new file mode 100644 index 0000000000..5153b4bb3c --- /dev/null +++ b/size-analysis-web-app/src/components/BundlePanel.jsx @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import BundleItem from './BundleItem'; +import { TEXT } from '../constants'; +class BundlePanel extends Component { + constructor(props) { + super(props); + this.state = { + currentBundle: this.props.bundle + + } + + } + componentDidMount() { + + } + componentDidUpdate(prevProps) { + if (prevProps.bundle !== this.props.bundle) { + this.setState({ currentBundle: this.props.bundle }); + } + + } + render() { + return ( + +
    + +
      + + {Array.from(this.state.currentBundle.keys()).map(key => + + )} +
    + + +
    + + + ); + } +} + +export default BundlePanel; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/DropDown.css b/size-analysis-web-app/src/components/DropDown.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/size-analysis-web-app/src/components/DropDown.jsx b/size-analysis-web-app/src/components/DropDown.jsx new file mode 100644 index 0000000000..c45344ec9d --- /dev/null +++ b/size-analysis-web-app/src/components/DropDown.jsx @@ -0,0 +1,21 @@ +import React from 'react'; + + +function DropDown(props) { + return ( + +
    + + +
    + + + ); +} + + +export default DropDown; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/Module.jsx b/size-analysis-web-app/src/components/Module.jsx new file mode 100644 index 0000000000..0df5158f42 --- /dev/null +++ b/size-analysis-web-app/src/components/Module.jsx @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import Symbols from './Symbols'; +import { TEXT } from '../constants'; +class Module extends Component { + constructor(props) { + super(props); + this.state = { + currentBundle: this.props.bundle + + } + this.handleUpdateSymbol = this.handleUpdateSymbol.bind(this); + this.handleUpdateModule = this.handleUpdateModule.bind(this); + this.isSymbolAdded = this.isSymbolAdded.bind(this); + this.isModuleAdded = this.isModuleAdded.bind(this); + + } + + componentDidMount() { + } + + componentDidUpdate(prevProps) { + if (prevProps.bundle !== this.props.bundle) { + this.setState({ currentBundle: this.props.bundle }); + } + + } + isSymbolAdded(symbolName, moduleName) { + if (this.state.currentBundle.has(moduleName) && this.state.currentBundle.get(moduleName).has(symbolName)) { + return true; + } + return false; + } + isModuleAdded(moduleName) { + if (this.state.currentBundle.has(moduleName) && this.state.currentBundle.get(moduleName).size === 0) { + return true; + } + return false; + } + handleUpdateSymbol(symbolName, moduleName) { + if (this.state.currentBundle.has(moduleName) && this.state.currentBundle.get(moduleName).has(symbolName)) { + this.props.handleRemoveSymbolFromBundle(symbolName, moduleName); + } else { + this.props.handleAddSymbolToBundle(symbolName, moduleName); + } + } + handleUpdateModule(moduleName) { + if (this.state.currentBundle.has(moduleName) && this.state.currentBundle.get(moduleName).size === 0) { + this.props.handleRemoveModuleFromBundle(moduleName); + } else { + this.props.handleAddModuleToBundle(moduleName); + } + } + render() { + const collapsibleReference = this.props.index.replace("@", "").replace("/", ""); + return ( + +
    + +
      + +
      +
    • { this.handleUpdateModule(this.props.name) }}> + {TEXT.moduleRepresentation} + + +
    • + {Object.keys(this.props.module).map((type) => + + + )} +
      +
    + +
    + + + ); + } +} + +export default Module; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/Symbol.jsx b/size-analysis-web-app/src/components/Symbol.jsx new file mode 100644 index 0000000000..6c8b3f31c4 --- /dev/null +++ b/size-analysis-web-app/src/components/Symbol.jsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import { TEXT } from '../constants'; +class Symbol extends Component { + constructor(props) { + super(props); + this.state = { + + } + } + componentDidMount() { + + + } + render() { + return ( + +
  • { this.props.handleUpdateSymbol(this.props.symbol, this.props.moduleName) }} + key={this.props.index}> + {this.props.symbol} + +
  • + + + ); + } +} + +export default Symbol; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/Symbols.jsx b/size-analysis-web-app/src/components/Symbols.jsx new file mode 100644 index 0000000000..d7009daa55 --- /dev/null +++ b/size-analysis-web-app/src/components/Symbols.jsx @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import Symbol from './Symbol'; +class Symbols extends Component { + constructor(props) { + super(props); + this.state = { + + } + } + componentDidMount() { + + } + render() { + return ( + + this.props.symbols.map((symbolOfType) => + + + ) + ); + } +} + +export default Symbols; \ No newline at end of file diff --git a/size-analysis-web-app/src/components/Visualization.jsx b/size-analysis-web-app/src/components/Visualization.jsx new file mode 100644 index 0000000000..92b45d3ec1 --- /dev/null +++ b/size-analysis-web-app/src/components/Visualization.jsx @@ -0,0 +1,248 @@ +import React, { Component } from 'react'; +import { TEXT } from '../constants'; +import '../App.css'; +import ReactJson from 'react-json-view' +import { HorizontalBar } from 'react-chartjs-2'; +import Alert from './Alert'; + + +class Symbol { + constructor(name, dependencies, sizeInBytes, sizeInBytesWithExternalDeps) { + this.name = name; + this.dependencies = dependencies; + this.sizeInBytes = sizeInBytes; + this.sizeInBytesWithExternalDeps = sizeInBytesWithExternalDeps; + } +} +class Visualization extends Component { + constructor(props) { + super(props); + this.state = { + errorMsg: "", + symbols: [], + symbolsOnDisplay: new Set(), + symbolsOffDisplay: new Set(), + jsonReport: {}, + symbolReport: {}, + symbolNameofReportOnDisplay: "root", + chartOptions: { + onClick: (e, item) => this.showReport(this.state.chartData.labels[item[0]._index]), + scales: { + yAxes: [{ + ticks: { + fontColor: 'white' + }, + }], + xAxes: [{ + ticks: { + fontColor: 'white' + }, + }] + }, + legend: { + + labels: { + fontColor: 'white' + }, + + } + }, + chartData: { + labels: [], + datasets: [ + { + label: 'Symbols Size Comparison', + + backgroundColor: 'rgba(255,203,43,0.2)', + borderColor: 'rgba(255,203,43,1)', + borderWidth: 2, + hoverBackgroundColor: 'rgba(255,203,43,0.4)', + hoverBorderColor: 'rgba(255,203,43,1)', + data: [] + } + ] + } + + } + this.handleOnFileUpload = this.handleOnFileUpload.bind(this); + this.handleChange = this.handleChange.bind(this); + this.extractChartLabel = this.extractChartLabel.bind(this); + this.extractChartData = this.extractChartData.bind(this); + this.updateSymbolDisplay = this.updateSymbolDisplay.bind(this); + this.isSymbolOnDisplay = this.isSymbolOnDisplay.bind(this); + this.isSymbolOnDisplay = this.isSymbolOnDisplay.bind(this); + this.clearErrorMessage = this.clearErrorMessage.bind(this); + } + + clearErrorMessage() { + this.setState({ + errorMsg: "" + }); + } + isSymbolOnDisplay(symbol) { + return !this.state.symbolsOffDisplay.has(symbol); + } + updateSymbolDisplay(e, symbol) { + e.stopPropagation(); + const charDataClone = Object.assign({}, this.state.chartData); + const symbolsOnDisplay = new Set(this.state.symbolsOnDisplay); + const symbolsOffDisplay = new Set(this.state.symbolsOffDisplay); + if (symbolsOffDisplay.has(symbol)) { + symbolsOnDisplay.add(symbol); + symbolsOffDisplay.delete(symbol); + + } else { + symbolsOnDisplay.delete(symbol); + symbolsOffDisplay.add(symbol); + + } + const symbolsOnDisplayArray = Array.from(symbolsOnDisplay); + symbolsOnDisplayArray.sort((a, b) => b.sizeInBytes - a.sizeInBytes); + const chartLabel = this.extractChartLabel(symbolsOnDisplayArray); + const chartData = this.extractChartData(symbolsOnDisplayArray); + charDataClone.labels = chartLabel; + charDataClone.datasets[0].data = chartData; + this.setState({ + + symbolsOffDisplay: symbolsOffDisplay, + symbolsOnDisplay: symbolsOnDisplay, + chartData: charDataClone + }); + + + } + handleOnFileUpload(e) { + const reportUploaded = e.target.files[0]; + if (reportUploaded.type.localeCompare('application/json') !== 0) { + this.setState({ + errorMsg: "file uploaded has to be in json format!" + }); + e.target.value = null; + return; + } + const reader = new FileReader() + reader.onload = async (e) => { + const jsonReport = JSON.parse(e.target.result); + const symbols = []; + const symbolsOnDisplay = new Set(); + for (let key of Object.keys(jsonReport)) { + const symbol = new Symbol(key, jsonReport[key].dependencies, jsonReport[key].sizeInBytes, jsonReport[key].sizeInBytesWithExternalDeps); + symbols.push(symbol); + symbolsOnDisplay.add(symbol); + + } + // sort symbols in descending order + symbols.sort((a, b) => b.sizeInBytes - a.sizeInBytes); + const chartLabel = this.extractChartLabel(symbols); + const chartData = this.extractChartData(symbols); + const charDataClone = Object.assign({}, this.state.chartData); + charDataClone.labels = chartLabel; + charDataClone.datasets[0].data = chartData; + + this.setState({ + symbols: symbols, + jsonReport: jsonReport, + symbolReport: jsonReport, + symbolsOnDisplay: symbolsOnDisplay, + chartData: charDataClone + }); + + }; + reader.readAsText(reportUploaded); + + } + + extractChartLabel(symbols) { + const labels = []; + for (const symbol of symbols) { + labels.push(symbol.name); + } + return labels; + } + showReport(symbolName) { + for (let key of Object.keys(this.state.jsonReport)) { + if (key.localeCompare(symbolName) === 0) { + this.setState({ + symbolReport: this.state.jsonReport[key], + symbolNameofReportOnDisplay: symbolName + }); + break; + } + } + + } + extractChartData(symbols) { + const data = []; + for (const symbol of symbols) { + data.push(symbol.sizeInBytes); + } + return data; + + } + handleChange(e) { + + this.setState({ + [e.target.name]: e.target.value + }); + } + render() { + const style = { + + "height": "inherit", + + "overflow": "scroll" + } + + + return ( +
    +
    + {this.state.errorMsg ? :
    +
    + + +
    +
    } + +
    +
    +
    +
      + {this.state.symbols.map(each => +
    • { this.showReport(each.name) }} + key={each.name} > + {each.name} {each.sizeInBytes} {TEXT.unit} + +
    • + + + )} +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + ); + } +} +export default Visualization; \ No newline at end of file diff --git a/size-analysis-web-app/src/constants.jsx b/size-analysis-web-app/src/constants.jsx new file mode 100644 index 0000000000..1e1f491f96 --- /dev/null +++ b/size-analysis-web-app/src/constants.jsx @@ -0,0 +1,35 @@ +export const COLOR = { + blue: '#059BE5', + orange: '#F6820D', + darkYellow: '#FFA611', + yellow: '#FFCB2B', + lightOrange: '#FF8964', + white: '#f0f2eb' +} + +export const SECTION = { + bundleCreation: 'Create Your Bundle', + bundleOverview: 'Bundle Overview' + +} +export const TEXT = { + calculateBtnText: 'Calculate', + addButtonText: '+', + deleteButtonText: 'x', + moduleRepresentation: '*', + sizePrompt: 'Size', + sizeAfterGzipPrompt: 'Size After Gzip', + unit: 'bytes', + outdatedBadgeText: 'Outdated', + chooseFile: 'Choose File' +} + +export const ENDPOINTS = { + retrieveFirebaseVersionFromNPM: 'retrieveFirebaseVersionFromNPM', + downloadPackageFromNPMGivenVersionAndReturnExportedSymbols: 'downloadPackageFromNPMGivenVersionAndReturnExportedSymbols', + generateSizeAnalysisReportGivenCustomBundle: 'generateSizeAnalysisReportGivenCustomBundle' +} + +export const API_ROOT = 'https://us-central1-fir-sdk-size-analysis.cloudfunctions.net/'; +//export const API_ROOT = 'http://localhost:5001/fir-sdk-size-analysis/us-central1/'; + diff --git a/size-analysis-web-app/src/index.css b/size-analysis-web-app/src/index.css new file mode 100644 index 0000000000..ec2585e8c0 --- /dev/null +++ b/size-analysis-web-app/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/size-analysis-web-app/src/index.jsx b/size-analysis-web-app/src/index.jsx new file mode 100644 index 0000000000..298951515a --- /dev/null +++ b/size-analysis-web-app/src/index.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import 'jquery/dist/jquery.min.js'; +import 'bootstrap/dist/js/bootstrap.bundle.min'; +import 'bootstrap/dist/css/bootstrap.min.css'; + +import './index.css'; +import App from './App'; + +import * as serviceWorker from './serviceWorker'; + +ReactDOM.render( + + + , + document.getElementById('root') +); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.unregister(); diff --git a/size-analysis-web-app/src/logo.svg b/size-analysis-web-app/src/logo.svg new file mode 100644 index 0000000000..6b60c1042f --- /dev/null +++ b/size-analysis-web-app/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/size-analysis-web-app/src/react-app-env.d.ts b/size-analysis-web-app/src/react-app-env.d.ts new file mode 100644 index 0000000000..0ec3f9b50b --- /dev/null +++ b/size-analysis-web-app/src/react-app-env.d.ts @@ -0,0 +1,18 @@ +/** + * @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. + */ + +/// diff --git a/size-analysis-web-app/src/serviceWorker.js b/size-analysis-web-app/src/serviceWorker.js new file mode 100644 index 0000000000..c8c5ed333c --- /dev/null +++ b/size-analysis-web-app/src/serviceWorker.js @@ -0,0 +1,158 @@ +/** + * @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. + */ + +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' } + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + }) + .catch(error => { + console.error(error.message); + }); + } +} diff --git a/size-analysis-web-app/src/setupTests.js b/size-analysis-web-app/src/setupTests.js new file mode 100644 index 0000000000..475bb51f99 --- /dev/null +++ b/size-analysis-web-app/src/setupTests.js @@ -0,0 +1,22 @@ +/** + * @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. + */ + +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom/extend-expect'; diff --git a/size-analysis-web-app/tsconfig.json b/size-analysis-web-app/tsconfig.json new file mode 100644 index 0000000000..f2850b7161 --- /dev/null +++ b/size-analysis-web-app/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": [ + "src" + ] +}