From 5309991ba6836b4af40bb4d46c763a75cee654f0 Mon Sep 17 00:00:00 2001 From: David Aurelio Date: Tue, 1 Mar 2016 04:40:40 -0800 Subject: [PATCH] Add new worker for code transform, optimization, and dependency extraction Summary:This adds a new worker implementation that - uses the existing transforms to transform code - optionally inline `__DEV__`, `process.env.NODE_ENV`, and `Platform.OS` - optionally eliminate branches of conditionals with constant conditions - extracts dependencies - optionally minifies This will land as part of a multi-commit stack, not in isolation Reviewed By: martinbigio Differential Revision: D2976677 fb-gh-sync-id: 38e317f90b6948b28ef2e3fe8b66fc0b9c75aa38 shipit-source-id: 38e317f90b6948b28ef2e3fe8b66fc0b9c75aa38 --- .../worker/__tests__/constant-folding-test.js | 112 ++++++++ .../__tests__/extract-dependencies-test.js | 96 +++++++ .../worker/__tests__/inline-test.js | 133 ++++++++++ .../worker/__tests__/minify-test.js | 111 ++++++++ .../worker/__tests__/worker-test.js | 244 ++++++++++++++++++ .../JSTransformer/worker/constant-folding.js | 82 ++++++ .../worker/extract-dependencies.js | 126 +++++++++ .../src/JSTransformer/worker/index.js | 79 ++++++ .../src/JSTransformer/worker/inline.js | 102 ++++++++ .../src/JSTransformer/worker/minify.js | 88 +++++++ 10 files changed, 1173 insertions(+) create mode 100644 react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js create mode 100644 react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js create mode 100644 react-packager/src/JSTransformer/worker/__tests__/inline-test.js create mode 100644 react-packager/src/JSTransformer/worker/__tests__/minify-test.js create mode 100644 react-packager/src/JSTransformer/worker/__tests__/worker-test.js create mode 100644 react-packager/src/JSTransformer/worker/constant-folding.js create mode 100644 react-packager/src/JSTransformer/worker/extract-dependencies.js create mode 100644 react-packager/src/JSTransformer/worker/index.js create mode 100644 react-packager/src/JSTransformer/worker/inline.js create mode 100644 react-packager/src/JSTransformer/worker/minify.js diff --git a/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js b/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js new file mode 100644 index 00000000..a39a1e97 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.autoMockOff(); +const babel = require('babel-core'); +const constantFolding = require('../constant-folding'); + +function parse(code) { + return babel.transform(code, {code: false, babelrc: false, compact: true}); +} + +describe('constant expressions', () => { + it('can optimize conditional expressions with constant conditions', () => { + const code = ` + a( + 'production'=="production", + 'production'!=='development', + false && 1 || 0 || 2, + true || 3, + 'android'==='ios' ? null : {}, + 'android'==='android' ? {a:1} : {a:0}, + 'foo'==='bar' ? b : c, + f() ? g() : h() + );`; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`a(true,true,2,true,{},{a:1},c,f()?g():h());`); + }); + + it('can optimize ternary expressions with constant conditions', () => { + const code = + `var a = true ? 1 : 2; + var b = 'android' == 'android' + ? ('production' != 'production' ? 'a' : 'A') + : 'i';`; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`var a=1;var b='A';`); + }); + + it('can optimize logical operator expressions with constant conditions', () => { + const code = ` + var a = true || 1; + var b = 'android' == 'android' && + 'production' != 'production' || null || "A";`; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`var a=true;var b="A";`); + }); + + it('can optimize logical operators with partly constant operands', () => { + const code = ` + var a = "truthy" || z(); + var b = "truthy" && z(); + var c = null && z(); + var d = null || z(); + var e = !1 && z(); + `; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`var a="truthy";var b=z();var c=null;var d=z();var e=false;`); + }); + + it('can remode an if statement with a falsy constant test', () => { + const code = ` + if ('production' === 'development' || false) { + var a = 1; + } + `; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(``); + }); + + it('can optimize if-else-branches with constant conditions', () => { + const code = ` + if ('production' == 'development') { + var a = 1; + var b = a + 2; + } else if ('development' == 'development') { + var a = 3; + var b = a + 4; + } else { + var a = 'b'; + } + `; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`{var a=3;var b=a+4;}`); + }); + + it('can optimize nested if-else constructs', () => { + const code = ` + if ('ios' === "android") { + if (true) { + require('a'); + } else { + require('b'); + } + } else if ('android' === 'android') { + if (true) { + require('c'); + } else { + require('d'); + } + } + `; + expect(constantFolding('arbitrary.js', parse(code)).code) + .toEqual(`{{require('c');}}`); + }); +}); diff --git a/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js b/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js new file mode 100644 index 00000000..a27def1d --- /dev/null +++ b/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.autoMockOff(); + +const extractDependencies = require('../extract-dependencies'); + +describe('Dependency extraction:', () => { + it('can extract calls to require', () => { + const code = `require('foo/bar'); + var React = require("React"); + var A = React.createClass({ + render: function() { + return require ( "Component" ); + } + }); + require + ('more');` + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies) + .toEqual(['foo/bar', 'React', 'Component', 'more']); + expect(dependencyOffsets).toEqual([8, 46, 147, 203]); + }); + + it('does not extract require method calls', () => { + const code = ` + require('a'); + foo.require('b'); + bar. + require ( 'c').require('d')require('e')`; + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['a', 'e']); + expect(dependencyOffsets).toEqual([15, 97]); + }); + + it('does not extract require calls from strings', () => { + const code = `require('foo'); + var React = '\\'require("React")'; + var a = ' // require("yadda")'; + var a = ' /* require("yadda") */'; + var A = React.createClass({ + render: function() { + return require ( "Component" ); + } + }); + " \\" require('more')";` + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['foo', 'Component']); + expect(dependencyOffsets).toEqual([8, 226]); + }); + + it('does not extract require calls in comments', () => { + const code = `require('foo')//require("not/this") + /* A comment here with a require('call') that should not be extracted */require('bar') + // ending comment without newline require("baz")`; + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['foo', 'bar']); + expect(dependencyOffsets).toEqual([8, 122]); + }); + + it('deduplicates dependencies', () => { + const code = `require('foo');require( "foo" ); + require("foo");`; + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['foo']); + expect(dependencyOffsets).toEqual([8, 24, 47]); + }); + + it('does not extract calls to function with names that start with "require"', () => { + const code = `arbitraryrequire('foo');`; + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual([]); + expect(dependencyOffsets).toEqual([]); + }); + + it('does not get confused by previous states', () => { + // yes, this was a bug + const code = `require("a");/* a comment */ var a = /[a]/.test('a');` + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['a']); + expect(dependencyOffsets).toEqual([8]); + }); +}); diff --git a/react-packager/src/JSTransformer/worker/__tests__/inline-test.js b/react-packager/src/JSTransformer/worker/__tests__/inline-test.js new file mode 100644 index 00000000..53fe1ed8 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/__tests__/inline-test.js @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.autoMockOff(); +const inline = require('../inline'); +const {transform, transformFromAst} = require('babel-core'); + +const babelOptions = { + babelrc: false, + compact: true, +}; + +function toString(ast) { + return normalize(transformFromAst(ast, babelOptions).code); +} + +function normalize(code) { + return transform(code, babelOptions).code; +} + +function toAst(code) { + return transform(code, {...babelOptions, code: false}).ast; +} + +describe('inline constants', () => { + it('replaces __DEV__ in the code', () => { + const code = `function a() { + var a = __DEV__ ? 1 : 2; + var b = a.__DEV__; + var c = function __DEV__(__DEV__) {}; + }` + const {ast} = inline('arbitrary.js', {code}, {dev: true}); + expect(toString(ast)).toEqual(normalize(code.replace(/__DEV__/, 'true'))); + }); + + it('replaces Platform.OS in the code if Platform is a global', () => { + const code = `function a() { + var a = Platform.OS; + var b = a.Platform.OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.OS/, '"ios"'))); + }); + + it('replaces Platform.OS in the code if Platform is a top level import', () => { + const code = ` + var Platform = require('Platform'); + function a() { + if (Platform.OS === 'android') a = function() {}; + var b = a.Platform.OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.OS/, '"ios"'))); + }); + + it('replaces require("Platform").OS in the code', () => { + const code = `function a() { + var a = require('Platform').OS; + var b = a.require('Platform').OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\('Platform'\)\.OS/, '"android"'))); + }); + + it('replaces React.Platform.OS in the code if React is a global', () => { + const code = `function a() { + var a = React.Platform.OS; + var b = a.React.Platform.OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/React\.Platform\.OS/, '"ios"'))); + }); + + it('replaces React.Platform.OS in the code if React is a top level import', () => { + const code = ` + var React = require('React'); + function a() { + if (React.Platform.OS === 'android') a = function() {}; + var b = a.React.Platform.OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/React.Platform\.OS/, '"ios"'))); + }); + + it('replaces require("React").Platform.OS in the code', () => { + const code = `function a() { + var a = require('React').Platform.OS; + var b = a.require('React').Platform.OS; + }` + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\('React'\)\.Platform\.OS/, '"android"'))); + }); + + it('replaces process.env.NODE_ENV in the code', () => { + const code = `function a() { + if (process.env.NODE_ENV === 'production') { + return require('Prod'); + } + return require('Dev'); + }` + const {ast} = inline('arbitrary.js', {code}, {dev: false}); + expect(toString(ast)).toEqual( + normalize(code.replace(/process\.env\.NODE_ENV/, '"production"'))); + }); + + it('replaces process.env.NODE_ENV in the code', () => { + const code = `function a() { + if (process.env.NODE_ENV === 'production') { + return require('Prod'); + } + return require('Dev'); + }` + const {ast} = inline('arbitrary.js', {code}, {dev: true}); + expect(toString(ast)).toEqual( + normalize(code.replace(/process\.env\.NODE_ENV/, '"development"'))); + }); + + it('accepts an AST as input', function() { + const code = `function ifDev(a,b){return __DEV__?a:b;}`; + const {ast} = inline('arbitrary.hs', {ast: toAst(code)}, {dev: false}); + expect(toString(ast)).toEqual(code.replace(/__DEV__/, 'false')) + }); +}); + diff --git a/react-packager/src/JSTransformer/worker/__tests__/minify-test.js b/react-packager/src/JSTransformer/worker/__tests__/minify-test.js new file mode 100644 index 00000000..e65594e7 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/__tests__/minify-test.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.autoMockOff(); + +const uglify = { + minify: jest.genMockFunction().mockImplementation(code => { + return { + code: code.replace(/(^|\W)\s+/g, '$1'), + map: {}, + }; + }), +}; +jest.setMock('uglify-js', uglify); + +const minify = require('../minify'); +const {any} = jasmine; + +describe('Minification:', () => { + const fileName = '/arbitrary/file.js'; + const DEPENDENCY_MARKER = '\u0002\ueffe\ue277\uead5'; + let map; + + beforeEach(() => { + uglify.minify.mockClear(); + map = {version: 3, sources: [fileName], mappings: ''}; + }); + + it('passes the transformed code to `uglify.minify`, wrapped in an immediately invoked function expression', () => { + const code = 'arbitrary(code)'; + minify('', code, {}, [], []); + expect(uglify.minify).toBeCalledWith( + `(function(){${code}}());`, any(Object)); + }); + + it('uses the passed module locals as parameters of the IIFE', () => { + const moduleLocals = ['arbitrary', 'parameters']; + minify('', '', {}, [], moduleLocals); + expect(uglify.minify).toBeCalledWith( + `(function(${moduleLocals}){}());`, any(Object)); + }); + + it('passes the transformed source map to `uglify.minify`', () => { + minify('', '', map, [], []); + const [, options] = uglify.minify.mock.calls[0]; + expect(options.inSourceMap).toEqual(map); + }); + + it('passes the file name as `outSourceMap` to `uglify.minify` (uglify uses it for the `file` field on the source map)', () => { + minify(fileName, '', {}, [], []); + const [, options] = uglify.minify.mock.calls[0]; + expect(options.outSourceMap).toEqual(fileName); + }); + + it('inserts a marker for every dependency offset before minifing', () => { + const code = ` + var React = require('React'); + var Immutable = require('Immutable');`; + const dependencyOffsets = [27, 67]; + const expectedCode = + code.replace(/require\('/g, '$&' + DEPENDENCY_MARKER); + + minify('', code, {}, dependencyOffsets, []); + expect(uglify.minify).toBeCalledWith( + `(function(){${expectedCode}}());`, any(Object)); + }); + + it('returns the code provided by uglify', () => { + const code = 'some(source) + code'; + uglify.minify.mockReturnValue({code: `!function(a,b,c){${code}}()`}); + + const result = minify('', '', {}, [], []); + expect(result.code).toBe(code); + }); + + it('extracts dependency offsets from the code provided by uglify', () => { + const code = ` + var a=r("${DEPENDENCY_MARKER}a-dependency"); + var b=r("\\x02\\ueffe\\ue277\\uead5b-dependency"); + var e=r(a()?'\\u0002\\ueffe\\ue277\\uead5c-dependency' + :'\x02\ueffe\ue277\uead5d-dependency');`; + uglify.minify.mockReturnValue({code: `!function(){${code}}());`}); + + const result = minify('', '', {}, [], []); + expect(result.dependencyOffsets).toEqual([15, 46, 81, 114]); + }); + + it('returns the source map object provided by uglify', () => { + uglify.minify.mockReturnValue({map, code: ''}); + const result = minify('', '', {}, [], []); + expect(result.map).toBe(map); + }); + + it('adds a `moduleLocals` object to the result that reflects the names of the minified module locals', () => { + const moduleLocals = ['arbitrary', 'parameters', 'here']; + uglify.minify.mockReturnValue({code: '(function(a,ll,d){}());'}); + const result = minify('', '', {}, [], moduleLocals); + expect(result.moduleLocals).toEqual({ + arbitrary: 'a', + parameters: 'll', + here: 'd', + }); + }); +}); diff --git a/react-packager/src/JSTransformer/worker/__tests__/worker-test.js b/react-packager/src/JSTransformer/worker/__tests__/worker-test.js new file mode 100644 index 00000000..a6acb668 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/__tests__/worker-test.js @@ -0,0 +1,244 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.autoMockOff(); +jest.mock('../constant-folding'); +jest.mock('../extract-dependencies'); +jest.mock('../inline'); +jest.mock('../minify'); + +const {transformCode} = require('..'); +const {any, objectContaining} = jasmine; + +describe('code transformation worker:', () => { + let extractDependencies, transform; + beforeEach(() => { + extractDependencies = + require('../extract-dependencies').mockReturnValue({}); + transform = jest.genMockFunction(); + }); + + it('calls the transform with file name, source code, and transform options', function() { + const filename = 'arbitrary/file.js'; + const sourceCode = 'arbitrary(code)'; + const transformOptions = {arbitrary: 'options'}; + transformCode(transform, filename, sourceCode, {transform: transformOptions}); + expect(transform).toBeCalledWith( + {filename, sourceCode, options: transformOptions}, any(Function)); + }); + + it('prefixes JSON files with an assignment to module.exports to make the code valid', function() { + const filename = 'arbitrary/file.json'; + const sourceCode = '{"arbitrary":"property"}'; + transformCode(transform, filename, sourceCode, {}); + expect(transform).toBeCalledWith( + {filename, sourceCode: `module.exports=${sourceCode}`}, any(Function)); + }); + + it('calls back with the result of the transform', done => { + const result = { + code: 'some.other(code)', + map: {} + }; + transform.mockImplementation((_, callback) => + callback(null, result)); + + transformCode(transform, 'filename', 'code', {}, (_, data) => { + expect(data).toEqual(objectContaining(result)); + done(); + }); + }); + + it('removes the leading assignment to `module.exports` before passing on the result if the file is a JSON file, even if minified', done => { + const result = { + code: 'p.exports={a:1,b:2}', + }; + transform.mockImplementation((_, callback) => + callback(null, result)); + + transformCode(transform, 'aribtrary/file.json', 'b', {}, (_, data) => { + expect(data.code).toBe('{a:1,b:2}'); + done(); + }); + }); + + it('calls back with any error yielded by the transform', done => { + const error = Error('arbitrary error'); + transform.mockImplementation((_, callback) => callback(error)); + transformCode(transform, 'filename', 'code', {}, e => { + expect(e).toBe(error); + done(); + }); + }); + + it('puts an empty `moduleLocals` object on the result', done => { + transform.mockImplementation( + (_, callback) => callback(null, {code: 'arbitrary'})); + transformCode(transform, 'filename', 'code', {}, (_, data) => { + expect(data.moduleLocals).toEqual({}); + done(); + }); + }); + + it('if a `moduleLocals` array is passed, the `moduleLocals` object is a key mirror of its items', done => { + transform.mockImplementation( + (_, callback) => callback(null, {code: 'arbitrary'})); + const moduleLocals = + ['arbitrary', 'list', 'containing', 'variable', 'names']; + + transformCode(transform, 'filename', 'code', {moduleLocals}, (_, data) => { + expect(data.moduleLocals).toEqual({ + arbitrary: 'arbitrary', + list: 'list', + containing: 'containing', + variable: 'variable', + names: 'names', + }); + done(); + }); + }); + + describe('dependency extraction:', () => { + let code; + + beforeEach(() => { + transform.mockImplementation( + (_, callback) => callback(null, {code})); + }); + + it('passes the transformed code the `extractDependencies`', done => { + code = 'arbitrary(code)'; + + transformCode(transform, 'filename', 'code', {}, (_, data) => { + expect(extractDependencies).toBeCalledWith(code); + done(); + }); + }); + + it('uses `dependencies` and `dependencyOffsets` provided by `extractDependencies` for the result', done => { + const dependencyData = { + dependencies: ['arbitrary', 'list', 'of', 'dependencies'], + dependencyOffsets: [12, 119, 185, 328, 471], + }; + extractDependencies.mockReturnValue(dependencyData); + + transformCode(transform, 'filename', 'code', {}, (_, data) => { + expect(data).toEqual(objectContaining(dependencyData)); + done(); + }); + }); + + it('does not extract requires if files are marked as "extern"', done => { + transformCode(transform, 'filename', 'code', {extern: true}, (_, {dependencies, dependencyOffsets}) => { + expect(extractDependencies).not.toBeCalled(); + expect(dependencies).toEqual([]); + expect(dependencyOffsets).toEqual([]); + done(); + }); + }); + + it('does not extract requires of JSON files', done => { + transformCode(transform, 'arbitrary.json', '{"arbitrary":"json"}', {}, (_, {dependencies, dependencyOffsets}) => { + expect(extractDependencies).not.toBeCalled(); + expect(dependencies).toEqual([]); + expect(dependencyOffsets).toEqual([]); + done(); + }); + }); + }); + + describe('Minifications:', () => { + let constantFolding, extractDependencies, inline, minify, options; + let transformResult, dependencyData, moduleLocals; + const filename = 'arbitrary/file.js'; + const foldedCode = 'arbitrary(folded(code));' + const foldedMap = {version: 3, sources: ['fold.js']} + + beforeEach(() => { + constantFolding = require('../constant-folding') + .mockReturnValue({code: foldedCode, map: foldedMap}); + extractDependencies = require('../extract-dependencies'); + inline = require('../inline'); + minify = require('../minify').mockReturnValue({}); + + moduleLocals = ['module', 'require', 'exports']; + options = {moduleLocals, minify: true}; + dependencyData = { + dependencies: ['a', 'b', 'c'], + dependencyOffsets: [100, 120, 140] + }; + + extractDependencies.mockImplementation( + code => code === foldedCode ? dependencyData : {}); + + transform.mockImplementation( + (_, callback) => callback(null, transformResult)); + }); + + it('passes the transform result to `inline` for constant inlining', done => { + transformResult = {map: {version: 3}, code: 'arbitrary(code)'}; + transformCode(transform, filename, 'code', options, () => { + expect(inline).toBeCalledWith(filename, transformResult, options); + done(); + }); + }); + + it('passes the result obtained from `inline` on to `constant-folding`', done => { + const inlineResult = {map: {version: 3, sources: []}, ast: {}}; + inline.mockReturnValue(inlineResult); + transformCode(transform, filename, 'code', options, () => { + expect(constantFolding).toBeCalledWith(filename, inlineResult); + done(); + }); + }); + + it('Uses the code obtained from `constant-folding` to extract dependencies', done => { + transformCode(transform, filename, 'code', options, () => { + expect(extractDependencies).toBeCalledWith(foldedCode); + done(); + }); + }); + + it('passes the code obtained from `constant-folding` to `minify`', done => { + transformCode(transform, filename, 'code', options, () => { + expect(minify).toBeCalledWith( + filename, + foldedCode, + foldedMap, + dependencyData.dependencyOffsets, + moduleLocals + ); + done(); + }); + }); + + it('uses the dependencies obtained from the optimized result', done => { + transformCode(transform, filename, 'code', options, (_, result) => { + expect(result.dependencies).toEqual(dependencyData.dependencies); + done(); + }); + }); + + it('uses data produced by `minify` for the result', done => { + const minifyResult = { + code: 'minified(code)', + dependencyOffsets: [10, 30, 60], + map: {version: 3, sources: ['minified.js']}, + moduleLocals: {module: 'x', require: 'y', exports: 'z'}, + }; + minify.mockReturnValue(minifyResult); + + transformCode(transform, 'filename', 'code', options, (_, result) => { + expect(result).toEqual(objectContaining(minifyResult)) + done(); + }); + }); + }); +}); diff --git a/react-packager/src/JSTransformer/worker/constant-folding.js b/react-packager/src/JSTransformer/worker/constant-folding.js new file mode 100644 index 00000000..acf14f68 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/constant-folding.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const babel = require('babel-core'); +const t = babel.types; + +const isLiteral = binaryExpression => + t.isLiteral(binaryExpression.left) && t.isLiteral(binaryExpression.right); + +const Conditional = { + exit(path) { + const node = path.node; + const test = node.test; + if (t.isLiteral(test)) { + if (test.value || node.alternate) { + path.replaceWith(test.value ? node.consequent : node.alternate); + } else if (!test.value) { + path.remove(); + } + } + }, +}; + +const plugin = { + visitor: { + BinaryExpression: { + exit(path) { + const node = path.node; + if (t.isLiteral(node.left) && t.isLiteral(node.right)) { + const result = path.evaluate(); + if (result.confident) { + path.replaceWith(t.valueToNode(result.value)); + } + } + }, + }, + ConditionalExpression: Conditional, + IfStatement: Conditional, + LogicalExpression: { + exit(path) { + const node = path.node; + const left = node.left; + if (t.isLiteral(left)) { + const value = t.isNullLiteral(left) ? null : left.value; + if (node.operator === '||') { + path.replaceWith(value ? left : node.right); + } else { + path.replaceWith(value ? node.right : left); + } + } + } + }, + UnaryExpression: { + exit(path) { + const node = path.node; + if (node.operator === '!' && t.isLiteral(node.argument)) { + path.replaceWith(t.valueToNode(!node.argument.value)); + } + } + }, + }, +}; + +function constantFolding(filename, transformResult) { + return babel.transformFromAst(transformResult.ast, transformResult.code, { + filename, + plugins: [plugin], + inputSourceMap: transformResult.map, + babelrc: false, + compact: true, + }) +} + +module.exports = constantFolding; + diff --git a/react-packager/src/JSTransformer/worker/extract-dependencies.js b/react-packager/src/JSTransformer/worker/extract-dependencies.js new file mode 100644 index 00000000..3ca22164 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/extract-dependencies.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const SINGLE_QUOTE = "'".charCodeAt(0); +const DOUBLE_QUOTE = '"'.charCodeAt(0); +const BACKSLASH = '\\'.charCodeAt(0); +const SLASH = '/'.charCodeAt(0); +const NEWLINE = '\n'.charCodeAt(0); +const ASTERISK = '*'.charCodeAt(0); + +// dollar is the only regex special character valid in identifiers +const escapeRegExp = identifier => identifier.replace(/[$]/g, '\\$'); + +function binarySearch(indexes, index) { + var low = 0; + var high = indexes.length - 1; + var i = 0; + + if (indexes[low] === index) { + return low; + } + while (high - low > 1) { + var current = low + ((high - low) >>> 1); // right shift divides by 2 and floors + if (index === indexes[current]) { + return current; + } + if (index > indexes[current]) { + low = current; + } else { + high = current; + } + } + return low; +} + +function indexOfCharCode(string, needle, i) { + for (var charCode; (charCode = string.charCodeAt(i)); i++) { + if (charCode === needle) { + return i; + } + } + return -1; +} + +const reRequire = /(?:^|[^.\s])\s*\brequire\s*\(\s*(['"])(.*?)\1/g; + +/** + * Extracts dependencies (module IDs imported with the `require` function) from + * a string containing code. + * The function is regular expression based for speed reasons. + * + * The code is traversed twice: + * 1. An array of ranges is built, where indexes 0-1, 2-3, 4-5, etc. are code, + * and indexes 1-2, 3-4, 5-6, etc. are string literals and comments. + * 2. require calls are extracted with a regular expression. + * + * The result of the dependency extraction is an de-duplicated array of + * dependencies, and an array of offsets to the string literals with module IDs. + * The index points to the opening quote. + */ +function extractDependencies(code) { + const ranges = [0]; + // are we currently in a quoted string? -> SINGLE_QUOTE or DOUBLE_QUOTE, else undefined + var currentQuote; + // scan the code for string literals and comments. + for (var i = 0, charCode; (charCode = code.charCodeAt(i)); i++) { + if (charCode === BACKSLASH) { + i += 1; + continue; + } + + if (charCode === SLASH && currentQuote === undefined) { + var next = code.charCodeAt(i + 1); + var end = undefined; + if (next === SLASH) { + end = indexOfCharCode(code, NEWLINE, i + 2); + } else if (next === ASTERISK) { + end = code.indexOf('*/', i + 2) + 1; // assume valid JS input here + } + if (end === -1) { + // if the comment does not end, it goes to the end of the file + end += code.length; + } + if (end !== undefined) { + ranges.push(i, end); + i = end; + continue; + } + } + + var isQuoteStart = currentQuote === undefined && + (charCode === SINGLE_QUOTE || charCode === DOUBLE_QUOTE); + if (isQuoteStart || currentQuote === charCode) { + ranges.push(i); + currentQuote = currentQuote === charCode ? undefined : charCode; + } + } + ranges.push(i); + + // extract dependencies + const dependencies = new Set(); + const dependencyOffsets = []; + for (var match; (match = reRequire.exec(code)); ) { + // check whether the match is in a code range, and not inside of a string + // literal or a comment + if (binarySearch(ranges, match.index) % 2 === 0) { + dependencies.add(match[2]); + dependencyOffsets.push( + match[0].length - match[2].length - 2 + match.index); + } + } + + return { + dependencyOffsets, + dependencies: Array.from(dependencies.values()), + }; +} + +module.exports = extractDependencies; diff --git a/react-packager/src/JSTransformer/worker/index.js b/react-packager/src/JSTransformer/worker/index.js new file mode 100644 index 00000000..bc6e9333 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/index.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const constantFolding = require('./constant-folding'); +const extractDependencies = require('./extract-dependencies'); +const inline = require('./inline'); +const minify = require('./minify'); + +function keyMirrorFromArray(array) { + var keyMirror = {}; + array.forEach(key => keyMirror[key] = key); + return keyMirror; +} + +function makeTransformParams(filename, sourceCode, options) { + if (filename.endsWith('.json')) { + sourceCode = 'module.exports=' + sourceCode; + } + return {filename, sourceCode, options}; +} + +function transformCode(transform, filename, sourceCode, options, callback) { + const params = makeTransformParams(filename, sourceCode, options.transform); + const moduleLocals = options.moduleLocals || []; + const isJson = filename.endsWith('.json'); + + transform(params, (error, transformed) => { + if (error) { + callback(error); + return; + } + + var code, map; + if (options.minify) { + const optimized = + constantFolding(filename, inline(filename, transformed, options)); + code = optimized.code; + map = optimized.map; + } else { + code = transformed.code; + map = transformed.map; + } + + if (isJson) { + code = code.replace(/^\w+\.exports=/, ''); + } + + const moduleLocals = options.moduleLocals || []; + const dependencyData = isJson || options.extern + ? {dependencies: [], dependencyOffsets: []} + : extractDependencies(code); + + var result; + if (options.minify) { + result = minify( + filename, code, map, dependencyData.dependencyOffsets, moduleLocals); + result.dependencies = dependencyData.dependencies; + } else { + result = dependencyData; + result.code = code; + result.map = map; + result.moduleLocals = keyMirrorFromArray(moduleLocals); + } + + callback(null, result); + }); +} + +module.exports = function(transform, filename, sourceCode, options, callback) { + transformCode(require(transform), filename, sourceCode, options || {}, callback); +}; +module.exports.transformCode = transformCode; // for easier testing diff --git a/react-packager/src/JSTransformer/worker/inline.js b/react-packager/src/JSTransformer/worker/inline.js new file mode 100644 index 00000000..2a419933 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/inline.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const babel = require('babel-core'); +const t = babel.types; + +const react = {name: 'React'}; +const platform = {name: 'Platform'}; +const os = {name: 'OS'}; +const requirePattern = {name: 'require'}; + +const env = {name: 'env'}; +const nodeEnv = {name: 'NODE_ENV'}; +const processId = {name: 'process'}; + +const dev = {name: '__DEV__'}; + +const isGlobal = (binding) => !binding; + +const isToplevelBinding = (binding) => isGlobal(binding) || !binding.scope.parent; + +const isRequireCall = (node, dependencyId, scope) => + t.isCallExpression(node) && + t.isIdentifier(node.callee, requirePattern) && + t.isStringLiteral(node.arguments[0], t.stringLiteral(dependencyId)); + +const isImport = (node, scope, pattern) => + t.isIdentifier(node, pattern) && + isToplevelBinding(scope.getBinding(pattern.name)) || + isRequireCall(node, pattern.name, scope); + +const isPlatformOS = (node, scope) => + t.isIdentifier(node.property, os) && + isImport(node.object, scope, platform); + +const isReactPlatformOS = (node, scope) => + t.isIdentifier(node.property, os) && + t.isMemberExpression(node.object) && + t.isIdentifier(node.object.property, platform) && + isImport(node.object.object, scope, react); + +const isProcessEnvNodeEnv = (node, scope) => + t.isIdentifier(node.property, nodeEnv) && + t.isMemberExpression(node.object) && + t.isIdentifier(node.object.property, env) && + t.isIdentifier(node.object.object, processId) && + isGlobal(scope.getBinding(processId.name)); + +const isDev = (node, parent, scope) => + t.isIdentifier(node, dev) && + isGlobal(scope.getBinding(dev.name)) && + !(t.isMemberExpression(parent)); + +const inlinePlugin = { + visitor: { + Identifier(path, state) { + if (isDev(path.node, path.parent, path.scope)) { + path.replaceWith(t.booleanLiteral(state.opts.dev)); + } + }, + MemberExpression(path, state) { + const node = path.node; + const scope = path.scope; + + if (isPlatformOS(node, scope) || isReactPlatformOS(node, scope)) { + path.replaceWith(t.stringLiteral(state.opts.platform)); + } + + if(isProcessEnvNodeEnv(node, scope)) { + path.replaceWith( + t.stringLiteral(state.opts.dev ? 'development' : 'production')); + } + }, + }, +}; + +const plugin = () => inlinePlugin; + +function inline(filename, transformResult, options) { + const code = transformResult.code; + const babelOptions = { + filename, + plugins: [[plugin, options]], + inputSourceMap: transformResult.map, + code: false, + babelrc: false, + compact: true, + }; + + return transformResult.ast + ? babel.transformFromAst(transformResult.ast, code, babelOptions) + : babel.transform(code, babelOptions); +} + +module.exports = inline; diff --git a/react-packager/src/JSTransformer/worker/minify.js b/react-packager/src/JSTransformer/worker/minify.js new file mode 100644 index 00000000..d3435918 --- /dev/null +++ b/react-packager/src/JSTransformer/worker/minify.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const uglify = require('uglify-js'); + +const MAGIC_MARKER = '\u0002\ueffe\ue277\uead5'; +const MAGIC_MARKER_SPLITTER = + /(?:\x02|\\u0002|\\x02)(?:\ueffe|\\ueffe)(?:\ue277|\\ue277)(?:\uead5|\\uead5)/; + +// IIFE = "immediately invoked function expression" +// we wrap modules in functions to allow the minifier to mangle local variables +function wrapCodeInIIFE(code, moduleLocals) { + return `(function(${moduleLocals.join(',')}){${code}}());`; +} + +function extractCodeFromIIFE(code) { + return code.substring(code.indexOf('{') + 1, code.lastIndexOf('}')); +} + +function extractModuleLocalsFromIIFE(code) { + return code.substring(code.indexOf('(', 1) + 1, code.indexOf(')')).split(','); +} + +function splitFirstElementAt(array, offset) { + const first = array.shift(); + array.unshift(first.slice(0, offset + 1), first.slice(offset + 1)); + return array; +} + +function insertMarkers(code, dependencyOffsets) { + return dependencyOffsets + .reduceRight(splitFirstElementAt, [code]) + .join(MAGIC_MARKER); +} + +function extractMarkers(codeWithMarkers) { + const dependencyOffsets = []; + const codeBits = codeWithMarkers.split(MAGIC_MARKER_SPLITTER); + var offset = 0; + for (var i = 0, max = codeBits.length - 1; i < max; i++) { + offset += codeBits[i].length; + dependencyOffsets.push(offset - 1); + } + + return {code: codeBits.join(''), dependencyOffsets}; +} + +function minify(filename, code, map, dependencyOffsets, moduleLocals) { + // before minifying, code is wrapped in an immediately invoked function + // expression, so that top level variables can be shortened safely + code = wrapCodeInIIFE( + // since we don't know where the strings specifying dependencies will be + // located in the minified code, we mark them with a special marker string + // and extract them afterwards. + // That way, post-processing code can use these positions + insertMarkers(code, dependencyOffsets), + moduleLocals + ); + + const minifyResult = uglify.minify(code, { + fromString: true, + inSourceMap: map, + outSourceMap: filename, + output: { + ascii_only: true, + screw_ie8: true, + }, + }); + + const minifiedModuleLocals = extractModuleLocalsFromIIFE(minifyResult.code); + const codeWithMarkers = extractCodeFromIIFE(minifyResult.code); + const result = extractMarkers(codeWithMarkers); + result.map = minifyResult.map; + result.moduleLocals = {}; + moduleLocals.forEach( + (key, i) => result.moduleLocals[key] = minifiedModuleLocals[i]); + + return result; +} + +module.exports = minify;