diff --git a/packages/metro-bundler/README.md b/packages/metro-bundler/README.md new file mode 100644 index 00000000..698b7806 --- /dev/null +++ b/packages/metro-bundler/README.md @@ -0,0 +1,155 @@ +React Native Packager +-------------------- + +React Native Packager is a project similar in scope to browserify or +webpack, it provides a CommonJS-like module system, JavaScript +compilation (ES6, Flow, JSX), bundling, and asset loading. + +The main difference is the Packager's focus on compilation and +bundling speed. We aim for a sub-second edit-reload +cycles. Additionally, we don't want users -- with large code bases -- +to wait more than a few seconds after starting the packager. + +The main deviation from the node module system is the support for our +proprietary module format known as `@providesModule`. However, we +discourage people from using this module format because going forward we +want to completely separate our infrastructure from React Native and +provide an experience most JavaScript developers are familiar with, +namely the node module format. We want to even go further, and let you +choose your own packager and asset pipeline or even integrate into +your existing infrastructure. + +React Native users need not to understand how the packager work, +however, this documentation might be useful for advanced users and +people who want to fix bugs or add features to the packager (patches +welcome!). + +## HTTP interface + +The main way you'd interact with the packager is via the HTTP +interface. The following is the list of endpoints and their respective +functions. + +### /path/to/moduleName.bundle + +Does the following in order: + +* parse out `path/to/moduleName` +* add a `.js` suffix to the path +* looks in your project root(s) for the file +* recursively collects all the dependencies from an in memory graph +* runs the modules through the transformer (might just be cached) +* concatenate the modules' content into a bundle +* responds to the client with the bundle (and a SourceMap URL) + +### /path/to/moduleName.map + +* if the package has been previously generated via the `.bundle` + endpoint then the source map will be generated from that package +* if the package has not been previously asked for, this will go + through the same steps outlined in the `.bundle` endpoint then + generate the source map. + +Note that source map generation currently assumes that the code has +been compiled with jstransform, which preserves line and column +numbers which allows us to generate source maps super fast. + +### /path/to/moduleName.(map|bundle) query params + +You can pass options for the bundle creation through the query params, +if the option is boolean `1/0` or `true/false` is accepted. + +Here are the current options the packager accepts: + +* `dev` boolean, defaults to true: sets a global `__DEV__` variable + which will effect how the React Native core libraries behave. +* `minify` boolean, defaults to false: whether to minify the bundle. +* `runModule` boolean, defaults to true: whether to require your entry + point module. So if you requested `moduleName`, this option will add + a `require('moduleName')` the end of your bundle. +* `inlineSourceMap` boolean, defaults to false: whether to inline + source maps. + +### /debug + +This is a page used for debugging, it offers a link to a single page : + +* Cached Packages: which shows you the packages that's been already + generated and cached + +## Programmatic API + +The packager is made of two things: + +* The core packager (which we're calling ReactPackager) +* The scripts, devtools launcher, server run etc. + +ReactPackager is how you mainly interact with the API. + +```js +var ReactPackager = require('./react-packager'); +``` + +### ReactPackager.buildBundle(serverOptions, bundleOptions) + +Builds a bundle according to the provided options. + +#### `serverOptions` + +* `projectRoots` array (required): Is the roots where your JavaScript + file will exist +* `blacklistRE` regexp: Is a pattern to ignore certain paths from the + packager +* `polyfillModuleName` array: Paths to polyfills you want to be + included at the start of the bundle +* `cacheVersion` string: used in creating the cache file +* `resetCache` boolean, defaults to false: whether to use the cache on + disk +* `transformModulePath` string: Path to the module used as a + JavaScript transformer +* `nonPersistent` boolean, defaults to false: Whether the server + should be used as a persistent deamon to watch files and update + itself +* `getTransformOptions` function: A function that acts as a middleware for + generating options to pass to the transformer based on the bundle being built. +* `reporter` object (required): An object with a single function `update` that + is called when events are happening: build updates, warnings, errors. + +#### `bundleOptions` + +* `entryFile` string (required): the entry file of the bundle, relative to one + of the roots. +* `dev` boolean (defaults to `true`): sets a global `__DEV__` variable + which will effect how the React Native core libraries behave. +* `minify` boolean: Whether to minify code and apply production optimizations. +* `runModule` boolean (defaults to `true`): whether to require your entry + point module. +* `inlineSourceMap` boolean, defaults to false: whether to inline + source maps. +* `platform` string: The target platform for the build +* `generateSourceMaps` boolean: Whether to generate source maps. +* `sourceMapUrl` string: The url of the source map (will be appended to + the bundle). + +## Debugging + +To get verbose output when running the packager, define an environment variable: + + export DEBUG=RNP:* + +You can combine this with other values, e.g. `DEBUG=babel,RNP:*`. Under the hood this uses the [`debug`](https://www.npmjs.com/package/debug) package, see its documentation for all the available options. + +The `/debug` endpoint discussed above is also useful. + +## FAQ + +### Can I use this in my own non-React Native project? + +Yes. It's not really tied to React Native, however feature development +is informed by React Native needs. + +### Why didn't you use webpack? + +We love webpack, however, when we tried on our codebase it was slower +than our developers would like it to be. You can find more discussion about +the subject [here](https://github.com/facebook/react-native/issues/5). diff --git a/packages/metro-bundler/babelRegisterOnly.js b/packages/metro-bundler/babelRegisterOnly.js new file mode 100644 index 00000000..a4f96afd --- /dev/null +++ b/packages/metro-bundler/babelRegisterOnly.js @@ -0,0 +1,38 @@ +/** + * 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'; + +Array.prototype.values || require('core-js/fn/array/values'); +Object.entries || require('core-js/fn/object/entries'); +Object.values || require('core-js/fn/object/values'); + +var _only = []; + +function registerOnly(onlyList) { + require('babel-register')(config(onlyList)); +} + +function config(onlyList) { + _only = _only.concat(onlyList); + return { + presets: ['es2015-node'], + plugins: [ + 'transform-flow-strip-types', + 'syntax-trailing-function-commas', + 'transform-object-rest-spread', + ], + only: _only, + retainLines: true, + sourceMaps: 'inline', + babelrc: false, + }; +} + +module.exports = exports = registerOnly; +exports.config = config; diff --git a/packages/metro-bundler/blacklist.js b/packages/metro-bundler/blacklist.js new file mode 100644 index 00000000..9a6b08c8 --- /dev/null +++ b/packages/metro-bundler/blacklist.js @@ -0,0 +1,47 @@ +/** + * 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'; + +var path = require('path'); + +// Don't forget to everything listed here to `package.json` +// modulePathIgnorePatterns. +var sharedBlacklist = [ + /node_modules[/\\]react[/\\]dist[/\\].*/, + + /website\/node_modules\/.*/, + + // TODO(jkassens, #9876132): Remove this rule when it's no longer needed. + 'Libraries/Relay/relay/tools/relayUnstableBatchedUpdates.js', + + /heapCapture\/bundle\.js/, +]; + +function escapeRegExp(pattern) { + if (Object.prototype.toString.call(pattern) === '[object RegExp]') { + return pattern.source.replace(/\//g, path.sep); + } else if (typeof pattern === 'string') { + var escaped = pattern.replace(/[\-\[\]\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + // convert the '/' into an escaped local file separator + return escaped.replace(/\//g,'\\' + path.sep); + } else { + throw new Error('Unexpected packager blacklist pattern: ' + pattern); + } +} + +function blacklist(additionalBlacklist) { + return new RegExp('(' + + (additionalBlacklist || []).concat(sharedBlacklist) + .map(escapeRegExp) + .join('|') + + ')$' + ); +} + +module.exports = blacklist; diff --git a/packages/metro-bundler/defaults.js b/packages/metro-bundler/defaults.js new file mode 100644 index 00000000..5a7ef364 --- /dev/null +++ b/packages/metro-bundler/defaults.js @@ -0,0 +1,44 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +exports.assetExts = [ + 'bmp', 'gif', 'jpg', 'jpeg', 'png', 'psd', 'svg', 'webp', // Image formats + 'm4v', 'mov', 'mp4', 'mpeg', 'mpg', 'webm', // Video formats + 'aac', 'aiff', 'caf', 'm4a', 'mp3', 'wav', // Audio formats + 'html', 'pdf', // Document formats +]; + +exports.moduleSystem = require.resolve('./react-packager/src/Resolver/polyfills/require.js'); + +exports.platforms = ['ios', 'android', 'windows', 'web']; + +exports.polyfills = [ + require.resolve('./react-packager/src/Resolver/polyfills/polyfills.js'), + require.resolve('./react-packager/src/Resolver/polyfills/console.js'), + require.resolve('./react-packager/src/Resolver/polyfills/error-guard.js'), + require.resolve('./react-packager/src/Resolver/polyfills/Number.es6.js'), + require.resolve('./react-packager/src/Resolver/polyfills/String.prototype.es6.js'), + require.resolve('./react-packager/src/Resolver/polyfills/Array.prototype.es6.js'), + require.resolve('./react-packager/src/Resolver/polyfills/Array.es6.js'), + require.resolve('./react-packager/src/Resolver/polyfills/Object.es7.js'), + require.resolve('./react-packager/src/Resolver/polyfills/babelHelpers.js'), +]; + +exports.providesModuleNodeModules = [ + 'react-native', + 'react-native-windows', +]; + +exports.runBeforeMainModule = [ + // Ensures essential globals are available and are patched correctly. + 'InitializeCore', +]; diff --git a/packages/metro-bundler/launchPackager.bat b/packages/metro-bundler/launchPackager.bat new file mode 100644 index 00000000..b0c395b9 --- /dev/null +++ b/packages/metro-bundler/launchPackager.bat @@ -0,0 +1,12 @@ +:: 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. + +@echo off +title React Packager +node "%~dp0..\local-cli\cli.js" start +pause +exit diff --git a/packages/metro-bundler/launchPackager.command b/packages/metro-bundler/launchPackager.command new file mode 100755 index 00000000..6f894cfd --- /dev/null +++ b/packages/metro-bundler/launchPackager.command @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# 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. + +# Set terminal title +echo -en "\033]0;React Packager\a" +clear + +THIS_DIR=$(dirname "$0") +pushd "$THIS_DIR" +source ./packager.sh +popd + +echo "Process terminated. Press to close the window" +read diff --git a/packages/metro-bundler/packager.sh b/packages/metro-bundler/packager.sh new file mode 100755 index 00000000..2c6a0eba --- /dev/null +++ b/packages/metro-bundler/packager.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# 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. + +THIS_DIR=$(dirname "$0") +node "$THIS_DIR/../local-cli/cli.js" start "$@" diff --git a/packages/metro-bundler/react-native-xcode.sh b/packages/metro-bundler/react-native-xcode.sh new file mode 100755 index 00000000..d136282f --- /dev/null +++ b/packages/metro-bundler/react-native-xcode.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# 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. + +# Bundle React Native app's code and image assets. +# This script is supposed to be invoked as part of Xcode build process +# and relies on environment variables (including PWD) set by Xcode + +case "$CONFIGURATION" in + Debug) + # Speed up build times by skipping the creation of the offline package for debug + # builds on the simulator since the packager is supposed to be running anyways. + if [[ "$PLATFORM_NAME" == *simulator ]]; then + echo "Skipping bundling for Simulator platform" + exit 0; + fi + + DEV=true + ;; + "") + echo "$0 must be invoked by Xcode" + exit 1 + ;; + *) + DEV=false + ;; +esac + +# Path to react-native folder inside node_modules +REACT_NATIVE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Xcode project file for React Native apps is located in ios/ subfolder +cd "${REACT_NATIVE_DIR}"/../.. + +# Define NVM_DIR and source the nvm.sh setup script +[ -z "$NVM_DIR" ] && export NVM_DIR="$HOME/.nvm" + +# Define entry file +ENTRY_FILE=${1:-index.ios.js} + +if [[ -s "$HOME/.nvm/nvm.sh" ]]; then + . "$HOME/.nvm/nvm.sh" +elif [[ -x "$(command -v brew)" && -s "$(brew --prefix nvm)/nvm.sh" ]]; then + . "$(brew --prefix nvm)/nvm.sh" +fi + +# Set up the nodenv node version manager if present +if [[ -x "$HOME/.nodenv/bin/nodenv" ]]; then + eval "$("$HOME/.nodenv/bin/nodenv" init -)" +fi + +[ -z "$NODE_BINARY" ] && export NODE_BINARY="node" + +nodejs_not_found() +{ + echo "error: Can't find '$NODE_BINARY' binary to build React Native bundle" >&2 + echo "If you have non-standard nodejs installation, select your project in Xcode," >&2 + echo "find 'Build Phases' - 'Bundle React Native code and images'" >&2 + echo "and change NODE_BINARY to absolute path to your node executable" >&2 + echo "(you can find it by invoking 'which node' in the terminal)" >&2 + exit 2 +} + +type $NODE_BINARY >/dev/null 2>&1 || nodejs_not_found + +# Print commands before executing them (useful for troubleshooting) +set -x +DEST=$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH + +if [[ "$CONFIGURATION" = "Debug" && ! "$PLATFORM_NAME" == *simulator ]]; then + PLISTBUDDY='/usr/libexec/PlistBuddy' + PLIST=$TARGET_BUILD_DIR/$INFOPLIST_PATH + IP=$(ipconfig getifaddr en0) + if [ -z "$IP" ]; then + IP=$(ifconfig | grep 'inet ' | grep -v 127.0.0.1 | cut -d\ -f2 | awk 'NR==1{print $1}') + fi + $PLISTBUDDY -c "Add NSAppTransportSecurity:NSExceptionDomains:localhost:NSTemporaryExceptionAllowsInsecureHTTPLoads bool true" "$PLIST" + $PLISTBUDDY -c "Add NSAppTransportSecurity:NSExceptionDomains:$IP.xip.io:NSTemporaryExceptionAllowsInsecureHTTPLoads bool true" "$PLIST" + echo "$IP.xip.io" > "$DEST/ip.txt" +fi + +BUNDLE_FILE="$DEST/main.jsbundle" + +$NODE_BINARY "$REACT_NATIVE_DIR/local-cli/cli.js" bundle \ + --entry-file "$ENTRY_FILE" \ + --platform ios \ + --dev $DEV \ + --reset-cache \ + --bundle-output "$BUNDLE_FILE" \ + --assets-dest "$DEST" + +if [[ ! $DEV && ! -f "$BUNDLE_FILE" ]]; then + echo "error: File $BUNDLE_FILE does not exist. This must be a bug with" >&2 + echo "React Native, please report it here: https://github.com/facebook/react-native/issues" + exit 2 +fi diff --git a/packages/metro-bundler/react-packager/.npmignore b/packages/metro-bundler/react-packager/.npmignore new file mode 100644 index 00000000..2113f106 --- /dev/null +++ b/packages/metro-bundler/react-packager/.npmignore @@ -0,0 +1,8 @@ +*~ +*.swm +*.swn +*.swp +*.DS_STORE +npm-debug.log +.cache +node_modules diff --git a/packages/metro-bundler/react-packager/index.js b/packages/metro-bundler/react-packager/index.js new file mode 100644 index 00000000..3406f681 --- /dev/null +++ b/packages/metro-bundler/react-packager/index.js @@ -0,0 +1,15 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +require('../../setupBabel')(); +module.exports = require('./react-packager'); diff --git a/packages/metro-bundler/react-packager/react-packager.js b/packages/metro-bundler/react-packager/react-packager.js new file mode 100644 index 00000000..b3676770 --- /dev/null +++ b/packages/metro-bundler/react-packager/react-packager.js @@ -0,0 +1,99 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Logger = require('./src/Logger'); + +const debug = require('debug'); +const invariant = require('fbjs/lib/invariant'); + +import type GlobalTransformCache from './src/lib/GlobalTransformCache'; +import type {Reporter} from './src/lib/reporting'; + +exports.createServer = createServer; +exports.Logger = Logger; + +type Options = { + globalTransformCache: ?GlobalTransformCache, + nonPersistent?: boolean, + projectRoots: Array, + reporter?: Reporter, + watch?: boolean, +}; + +type StrictOptions = { + globalTransformCache: ?GlobalTransformCache, + nonPersistent?: boolean, + projectRoots: Array, + reporter: Reporter, + watch?: boolean, +}; + +exports.buildBundle = function(options: Options, bundleOptions: {}) { + var server = createNonPersistentServer(options); + return server.buildBundle(bundleOptions) + .then(p => { + server.end(); + return p; + }); +}; + +exports.getOrderedDependencyPaths = function(options: Options, bundleOptions: {}) { + var server = createNonPersistentServer(options); + return server.getOrderedDependencyPaths(bundleOptions) + .then(function(paths) { + server.end(); + return paths; + }); +}; + +function enableDebug() { + // react-packager logs debug messages using the 'debug' npm package, and uses + // the following prefix throughout. + // To enable debugging, we need to set our pattern or append it to any + // existing pre-configured pattern to avoid disabling logging for + // other packages + var debugPattern = 'RNP:*'; + var existingPattern = debug.load(); + if (existingPattern) { + debugPattern += ',' + existingPattern; + } + debug.enable(debugPattern); +} + +function createServer(options: StrictOptions) { + // the debug module is configured globally, we need to enable debugging + // *before* requiring any packages that use `debug` for logging + if (options.verbose) { + enableDebug(); + } + + // Some callsites may not be Flowified yet. + invariant(options.reporter != null, 'createServer() requires reporter'); + const serverOptions = Object.assign({}, options); + delete serverOptions.verbose; + var Server = require('./src/Server'); + return new Server(serverOptions); +} + +function createNonPersistentServer(options: Options) { + const serverOptions = { + // It's unsound to set-up the reporter here, + // but this allows backward compatibility. + reporter: options.reporter == null + ? require('./src/lib/reporting').nullReporter + : options.reporter, + ...options, + watch: !options.nonPersistent, + }; + return createServer(serverOptions); +} diff --git a/packages/metro-bundler/react-packager/rn-babelrc.json b/packages/metro-bundler/react-packager/rn-babelrc.json new file mode 100644 index 00000000..bbc20ea9 --- /dev/null +++ b/packages/metro-bundler/react-packager/rn-babelrc.json @@ -0,0 +1,4 @@ +{ + "presets": [ "react-native" ], + "plugins": [] +} diff --git a/packages/metro-bundler/react-packager/src/AssetServer/__tests__/AssetServer-test.js b/packages/metro-bundler/react-packager/src/AssetServer/__tests__/AssetServer-test.js new file mode 100644 index 00000000..69b442bf --- /dev/null +++ b/packages/metro-bundler/react-packager/src/AssetServer/__tests__/AssetServer-test.js @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2013-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.disableAutomock(); + +jest.mock('fs'); + +const AssetServer = require('../'); +const crypto = require('crypto'); +const fs = require('fs'); + +const {objectContaining} = jasmine; + +describe('AssetServer', () => { + beforeEach(() => { + const NodeHaste = require('../../node-haste'); + NodeHaste.getAssetDataFromName = + require.requireActual('../../node-haste/lib/getAssetDataFromName'); + }); + + describe('assetServer.get', () => { + it('should work for the simple case', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b.png': 'b image', + 'b@2x.png': 'b2 image', + } + } + }); + + return Promise.all([ + server.get('imgs/b.png'), + server.get('imgs/b@1x.png'), + ]).then(resp => + resp.forEach(data => + expect(data).toBe('b image') + ) + ); + }); + + it('should work for the simple case with platform ext', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b.ios.png': 'b ios image', + 'b.android.png': 'b android image', + 'c.png': 'c general image', + 'c.android.png': 'c android image', + } + } + }); + + return Promise.all([ + server.get('imgs/b.png', 'ios').then( + data => expect(data).toBe('b ios image') + ), + server.get('imgs/b.png', 'android').then( + data => expect(data).toBe('b android image') + ), + server.get('imgs/c.png', 'android').then( + data => expect(data).toBe('c android image') + ), + server.get('imgs/c.png', 'ios').then( + data => expect(data).toBe('c general image') + ), + server.get('imgs/c.png').then( + data => expect(data).toBe('c general image') + ), + ]); + }); + + + it('should work for the simple case with jpg', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png', 'jpg'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b.png': 'png image', + 'b.jpg': 'jpeg image', + } + } + }); + + return Promise.all([ + server.get('imgs/b.jpg'), + server.get('imgs/b.png'), + ]).then(data => + expect(data).toEqual([ + 'jpeg image', + 'png image', + ]) + ); + }); + + it('should pick the bigger one', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b@1x.png': 'b1 image', + 'b@2x.png': 'b2 image', + 'b@4x.png': 'b4 image', + 'b@4.5x.png': 'b4.5 image', + } + } + }); + + return server.get('imgs/b@3x.png').then(data => + expect(data).toBe('b4 image') + ); + }); + + it('should pick the bigger one with platform ext', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b@1x.png': 'b1 image', + 'b@2x.png': 'b2 image', + 'b@4x.png': 'b4 image', + 'b@4.5x.png': 'b4.5 image', + 'b@1x.ios.png': 'b1 ios image', + 'b@2x.ios.png': 'b2 ios image', + 'b@4x.ios.png': 'b4 ios image', + 'b@4.5x.ios.png': 'b4.5 ios image', + } + } + }); + + return Promise.all([ + server.get('imgs/b@3x.png').then(data => + expect(data).toBe('b4 image') + ), + server.get('imgs/b@3x.png', 'ios').then(data => + expect(data).toBe('b4 ios image') + ), + ]); + }); + + it('should support multiple project roots', () => { + const server = new AssetServer({ + projectRoots: ['/root', '/root2'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b.png': 'b image', + }, + }, + 'root2': { + 'newImages': { + 'imgs': { + 'b@1x.png': 'b1 image', + }, + }, + }, + }); + + return server.get('newImages/imgs/b.png').then(data => + expect(data).toBe('b1 image') + ); + }); + }); + + describe('assetServer.getAssetData', () => { + it('should get assetData', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b@1x.png': 'b1 image', + 'b@2x.png': 'b2 image', + 'b@4x.png': 'b4 image', + 'b@4.5x.png': 'b4.5 image', + } + } + }); + + return server.getAssetData('imgs/b.png').then(data => { + expect(data).toEqual(objectContaining({ + type: 'png', + name: 'b', + scales: [1, 2, 4, 4.5], + files: [ + '/root/imgs/b@1x.png', + '/root/imgs/b@2x.png', + '/root/imgs/b@4x.png', + '/root/imgs/b@4.5x.png', + ], + })); + }); + }); + + it('should get assetData for non-png images', () => { + const server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['png', 'jpeg'], + }); + + fs.__setMockFilesystem({ + 'root': { + imgs: { + 'b@1x.jpg': 'b1 image', + 'b@2x.jpg': 'b2 image', + 'b@4x.jpg': 'b4 image', + 'b@4.5x.jpg': 'b4.5 image', + } + } + }); + + return server.getAssetData('imgs/b.jpg').then(data => { + expect(data).toEqual(objectContaining({ + type: 'jpg', + name: 'b', + scales: [1, 2, 4, 4.5], + files: [ + '/root/imgs/b@1x.jpg', + '/root/imgs/b@2x.jpg', + '/root/imgs/b@4x.jpg', + '/root/imgs/b@4.5x.jpg', + ], + })); + }); + }); + + describe('hash:', () => { + let server, mockFS; + beforeEach(() => { + server = new AssetServer({ + projectRoots: ['/root'], + assetExts: ['jpg'], + }); + + mockFS = { + 'root': { + imgs: { + 'b@1x.jpg': 'b1 image', + 'b@2x.jpg': 'b2 image', + 'b@4x.jpg': 'b4 image', + 'b@4.5x.jpg': 'b4.5 image', + } + } + }; + + fs.__setMockFilesystem(mockFS); + }); + + it('uses the file contents to build the hash', () => { + const hash = crypto.createHash('md5'); + for (const name in mockFS.root.imgs) { + hash.update(mockFS.root.imgs[name]); + } + + return server.getAssetData('imgs/b.jpg').then(data => + expect(data).toEqual(objectContaining({hash: hash.digest('hex')})) + ); + }); + + it('changes the hash when the passed-in file watcher emits an `all` event', () => { + return server.getAssetData('imgs/b.jpg').then(initialData => { + mockFS.root.imgs['b@4x.jpg'] = 'updated data'; + server.onFileChange('all', '/root/imgs/b@4x.jpg'); + return server.getAssetData('imgs/b.jpg').then(data => + expect(data.hash).not.toEqual(initialData.hash) + ); + }); + }); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/AssetServer/index.js b/packages/metro-bundler/react-packager/src/AssetServer/index.js new file mode 100644 index 00000000..a267eea6 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/AssetServer/index.js @@ -0,0 +1,242 @@ +/** + * 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'; + +const crypto = require('crypto'); +const declareOpts = require('../lib/declareOpts'); +const denodeify = require('denodeify'); +const fs = require('fs'); +const getAssetDataFromName = require('../node-haste').getAssetDataFromName; +const path = require('path'); + +const createTimeoutPromise = (timeout) => new Promise((resolve, reject) => { + setTimeout(reject, timeout, 'fs operation timeout'); +}); +function timeoutableDenodeify(fsFunc, timeout) { + return function raceWrapper(...args) { + return Promise.race([ + createTimeoutPromise(timeout), + denodeify(fsFunc).apply(this, args) + ]); + }; +} + +const FS_OP_TIMEOUT = parseInt(process.env.REACT_NATIVE_FSOP_TIMEOUT, 10) || 15000; + +const stat = timeoutableDenodeify(fs.stat, FS_OP_TIMEOUT); +const readDir = timeoutableDenodeify(fs.readdir, FS_OP_TIMEOUT); +const readFile = timeoutableDenodeify(fs.readFile, FS_OP_TIMEOUT); + +const validateOpts = declareOpts({ + projectRoots: { + type: 'array', + required: true, + }, + assetExts: { + type: 'array', + required: true, + }, +}); + +class AssetServer { + constructor(options) { + const opts = validateOpts(options); + this._roots = opts.projectRoots; + this._assetExts = opts.assetExts; + this._hashes = new Map(); + this._files = new Map(); + } + + get(assetPath, platform = null) { + const assetData = getAssetDataFromName(assetPath, new Set([platform])); + return this._getAssetRecord(assetPath, platform).then(record => { + for (let i = 0; i < record.scales.length; i++) { + if (record.scales[i] >= assetData.resolution) { + return readFile(record.files[i]); + } + } + + return readFile(record.files[record.files.length - 1]); + }); + } + + getAssetData(assetPath, platform = null) { + const nameData = getAssetDataFromName(assetPath, new Set([platform])); + const data = { + name: nameData.name, + type: nameData.type, + }; + + return this._getAssetRecord(assetPath, platform).then(record => { + data.scales = record.scales; + data.files = record.files; + + if (this._hashes.has(assetPath)) { + data.hash = this._hashes.get(assetPath); + return data; + } + + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5'); + hashFiles(data.files.slice(), hash, error => { + if (error) { + reject(error); + } else { + data.hash = hash.digest('hex'); + this._hashes.set(assetPath, data.hash); + data.files.forEach(f => this._files.set(f, assetPath)); + resolve(data); + } + }); + }); + }); + } + + onFileChange(type, filePath) { + this._hashes.delete(this._files.get(filePath)); + } + + /** + * Given a request for an image by path. That could contain a resolution + * postfix, we need to find that image (or the closest one to it's resolution) + * in one of the project roots: + * + * 1. We first parse the directory of the asset + * 2. We check to find a matching directory in one of the project roots + * 3. We then build a map of all assets and their scales in this directory + * 4. Then try to pick platform-specific asset records + * 5. Then pick the closest resolution (rounding up) to the requested one + */ + _getAssetRecord(assetPath, platform = null) { + const filename = path.basename(assetPath); + + return ( + this._findRoot( + this._roots, + path.dirname(assetPath), + assetPath, + ) + .then(dir => Promise.all([ + dir, + readDir(dir), + ])) + .then(res => { + const dir = res[0]; + const files = res[1]; + const assetData = getAssetDataFromName(filename, new Set([platform])); + + const map = this._buildAssetMap(dir, files, platform); + + let record; + if (platform != null){ + record = map[getAssetKey(assetData.assetName, platform)] || + map[assetData.assetName]; + } else { + record = map[assetData.assetName]; + } + + if (!record) { + throw new Error( + `Asset not found: ${assetPath} for platform: ${platform}` + ); + } + + return record; + }) + ); + } + + _findRoot(roots, dir, debugInfoFile) { + return Promise.all( + roots.map(root => { + const absRoot = path.resolve(root); + // important: we want to resolve root + dir + // to ensure the requested path doesn't traverse beyond root + const absPath = path.resolve(root, dir); + return stat(absPath).then(fstat => { + // keep asset requests from traversing files + // up from the root (e.g. ../../../etc/hosts) + if (!absPath.startsWith(absRoot)) { + return {path: absPath, isValid: false}; + } + return {path: absPath, isValid: fstat.isDirectory()}; + }, _ => { + return {path: absPath, isValid: false}; + }); + }) + ).then(stats => { + for (let i = 0; i < stats.length; i++) { + if (stats[i].isValid) { + return stats[i].path; + } + } + + const rootsString = roots.map(s => `'${s}'`).join(', '); + throw new Error( + `'${debugInfoFile}' could not be found, because '${dir}' is not a ` + + `subdirectory of any of the roots (${rootsString})`, + ); + }); + } + + _buildAssetMap(dir, files, platform) { + const assets = files.map(this._getAssetDataFromName.bind(this, new Set([platform]))); + const map = Object.create(null); + assets.forEach(function(asset, i) { + const file = files[i]; + const assetKey = getAssetKey(asset.assetName, asset.platform); + let record = map[assetKey]; + if (!record) { + record = map[assetKey] = { + scales: [], + files: [], + }; + } + + let insertIndex; + const length = record.scales.length; + + for (insertIndex = 0; insertIndex < length; insertIndex++) { + if (asset.resolution < record.scales[insertIndex]) { + break; + } + } + record.scales.splice(insertIndex, 0, asset.resolution); + record.files.splice(insertIndex, 0, path.join(dir, file)); + }); + + return map; + } + + _getAssetDataFromName(platform, file) { + return getAssetDataFromName(file, platform); + } +} + +function getAssetKey(assetName, platform) { + if (platform != null) { + return `${assetName} : ${platform}`; + } else { + return assetName; + } +} + +function hashFiles(files, hash, callback) { + if (!files.length) { + callback(null); + return; + } + + fs.createReadStream(files.shift()) + .on('data', data => hash.update(data)) + .once('end', () => hashFiles(files, hash, callback)) + .once('error', error => callback(error)); +} + +module.exports = AssetServer; diff --git a/packages/metro-bundler/react-packager/src/Bundler/Bundle.js b/packages/metro-bundler/react-packager/src/Bundler/Bundle.js new file mode 100644 index 00000000..04a3d934 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/Bundle.js @@ -0,0 +1,437 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const BundleBase = require('./BundleBase'); +const ModuleTransport = require('../lib/ModuleTransport'); + +const _ = require('lodash'); +const crypto = require('crypto'); +const debug = require('debug')('RNP:Bundle'); +const invariant = require('fbjs/lib/invariant'); + +const {fromRawMappings} = require('./source-map'); + +import type {SourceMap, CombinedSourceMap, MixedSourceMap} from '../lib/SourceMap'; +import type {GetSourceOptions, FinalizeOptions} from './BundleBase'; + +export type Unbundle = { + startupModules: Array<*>, + lazyModules: Array<*>, + groups: Map>, +}; + +type SourceMapFormat = 'undetermined' | 'indexed' | 'flattened'; + +const SOURCEMAPPING_URL = '\n\/\/# sourceMappingURL='; + +class Bundle extends BundleBase { + + _dev: boolean | void; + _inlineSourceMap: string | void; + _minify: boolean | void; + _numRequireCalls: number; + _ramBundle: Unbundle | null; + _ramGroups: Array | void; + _sourceMap: string | null; + _sourceMapFormat: SourceMapFormat; + _sourceMapUrl: string | void; + + constructor({sourceMapUrl, dev, minify, ramGroups}: { + sourceMapUrl?: string, + dev?: boolean, + minify?: boolean, + ramGroups?: Array, + } = {}) { + super(); + this._sourceMap = null; + this._sourceMapFormat = 'undetermined'; + this._sourceMapUrl = sourceMapUrl; + this._numRequireCalls = 0; + this._dev = dev; + this._minify = minify; + + this._ramGroups = ramGroups; + this._ramBundle = null; // cached RAM Bundle + } + + addModule( + /** + * $FlowFixMe: this code is inherently incorrect, because it modifies the + * signature of the base class function "addModule". That means callsites + * using an instance typed as the base class would be broken. This must be + * refactored. + */ + resolver: {wrapModule: (options: any) => Promise<{code: any, map: any}>}, + resolutionResponse: mixed, + module: mixed, + /* $FlowFixMe: erroneous change of signature. */ + moduleTransport: ModuleTransport, + /* $FlowFixMe: erroneous change of signature. */ + ): Promise { + const index = super.addModule(moduleTransport); + return resolver.wrapModule({ + resolutionResponse, + module, + name: moduleTransport.name, + code: moduleTransport.code, + map: moduleTransport.map, + meta: moduleTransport.meta, + minify: this._minify, + dev: this._dev, + }).then(({code, map}) => { + // If we get a map from the transformer we'll switch to a mode + // were we're combining the source maps as opposed to + if (map) { + const usesRawMappings = isRawMappings(map); + + if (this._sourceMapFormat === 'undetermined') { + this._sourceMapFormat = usesRawMappings ? 'flattened' : 'indexed'; + } else if (usesRawMappings && this._sourceMapFormat === 'indexed') { + throw new Error( + `Got at least one module with a full source map, but ${ + moduleTransport.sourcePath} has raw mappings` + ); + } else if (!usesRawMappings && this._sourceMapFormat === 'flattened') { + throw new Error( + `Got at least one module with raw mappings, but ${ + moduleTransport.sourcePath} has a full source map` + ); + } + } + + this.replaceModuleAt( + index, new ModuleTransport({...moduleTransport, code, map})); + }); + } + + finalize(options: FinalizeOptions) { + options = options || {}; + if (options.runMainModule) { + /* $FlowFixMe: this is unsound, as nothing enforces runBeforeMainModule + * to be available if `runMainModule` is true. Refactor. */ + options.runBeforeMainModule.forEach(this._addRequireCall, this); + /* $FlowFixMe: this is unsound, as nothing enforces the module ID to have + * been set beforehand. */ + this._addRequireCall(this.getMainModuleId()); + } + + super.finalize(options); + } + + _addRequireCall(moduleId: string) { + const code = `;require(${JSON.stringify(moduleId)});`; + const name = 'require-' + moduleId; + super.addModule(new ModuleTransport({ + name, + id: -this._numRequireCalls - 1, + code, + virtual: true, + sourceCode: code, + sourcePath: name + '.js', + meta: {preloaded: true}, + })); + this._numRequireCalls += 1; + } + + _getInlineSourceMap(dev) { + if (this._inlineSourceMap == null) { + const sourceMap = this.getSourceMapString({excludeSource: true, dev}); + /*eslint-env node*/ + const encoded = new Buffer(sourceMap).toString('base64'); + this._inlineSourceMap = 'data:application/json;base64,' + encoded; + } + return this._inlineSourceMap; + } + + getSource(options: GetSourceOptions) { + this.assertFinalized(); + + options = options || {}; + + let source = super.getSource(options); + + if (options.inlineSourceMap) { + source += SOURCEMAPPING_URL + this._getInlineSourceMap(options.dev); + } else if (this._sourceMapUrl) { + source += SOURCEMAPPING_URL + this._sourceMapUrl; + } + + return source; + } + + getUnbundle(): Unbundle { + this.assertFinalized(); + if (!this._ramBundle) { + const modules = this.getModules().slice(); + + // separate modules we need to preload from the ones we don't + const [startupModules, lazyModules] = partition(modules, shouldPreload); + + const ramGroups = this._ramGroups; + let groups; + this._ramBundle = { + startupModules, + lazyModules, + get groups() { + if (!groups) { + groups = createGroups(ramGroups || [], lazyModules); + } + return groups; + }, + }; + } + + return this._ramBundle; + } + + invalidateSource() { + debug('invalidating bundle'); + super.invalidateSource(); + this._sourceMap = null; + } + + /** + * Combine each of the sourcemaps multiple modules have into a single big + * one. This works well thanks to a neat trick defined on the sourcemap spec + * that makes use of of the `sections` field to combine sourcemaps by adding + * an offset. This is supported only by Chrome for now. + */ + _getCombinedSourceMaps(options): CombinedSourceMap { + const result = { + version: 3, + file: this._getSourceMapFile(), + sections: [], + }; + + let line = 0; + this.getModules().forEach(module => { + let map = module.map == null || module.virtual + ? generateSourceMapForVirtualModule(module) + : module.map; + + invariant( + !Array.isArray(map), + `Unexpected raw mappings for ${module.sourcePath}`, + ); + + if (options.excludeSource && 'sourcesContent' in map) { + map = {...map, sourcesContent: []}; + } + + result.sections.push({ + offset: { line: line, column: 0 }, + map: (map: MixedSourceMap), + }); + line += module.code.split('\n').length; + }); + + return result; + } + + getSourceMap(options: {excludeSource?: boolean}): MixedSourceMap { + this.assertFinalized(); + + return this._sourceMapFormat === 'indexed' + ? this._getCombinedSourceMaps(options) + : fromRawMappings(this.getModules()).toMap(); + } + + getSourceMapString(options: {excludeSource?: boolean}): string { + if (this._sourceMapFormat === 'indexed') { + return JSON.stringify(this.getSourceMap(options)); + } + + // The following code is an optimization specific to the development server: + // 1. generator.toSource() is faster than JSON.stringify(generator.toMap()). + // 2. caching the source map unless there are changes saves time in + // development settings. + let map = this._sourceMap; + if (map == null) { + debug('Start building flat source map'); + map = this._sourceMap = fromRawMappings(this.getModules()).toString(); + debug('End building flat source map'); + } else { + debug('Returning cached source map'); + } + return map; + } + + getEtag() { + /* $FlowFixMe: we must pass options, or rename the + * base `getSource` function, as it does not actually need options. */ + var eTag = crypto.createHash('md5').update(this.getSource()).digest('hex'); + return eTag; + } + + _getSourceMapFile() { + return this._sourceMapUrl + ? this._sourceMapUrl.replace('.map', '.bundle') + : 'bundle.js'; + } + + getJSModulePaths() { + return this.getModules() + // Filter out non-js files. Like images etc. + .filter(module => !module.virtual) + .map(module => module.sourcePath); + } + + getDebugInfo() { + return [ + /* $FlowFixMe: this is unsound as the module ID could be unset. */ + '

Main Module:

' + this.getMainModuleId() + '
', + '', + '

Module paths and transformed code:

', + this.getModules().map(function(m) { + return '

Path:

' + m.sourcePath + '

Source:

' + + '
'; + }).join('\n'), + ].join('\n'); + } + + setRamGroups(ramGroups: Array) { + this._ramGroups = ramGroups; + } +} + +function generateSourceMapForVirtualModule(module): SourceMap { + // All lines map 1-to-1 + let mappings = 'AAAA;'; + + for (let i = 1; i < module.code.split('\n').length; i++) { + mappings += 'AACA;'; + } + + return { + version: 3, + sources: [ module.sourcePath ], + names: [], + mappings: mappings, + file: module.sourcePath, + sourcesContent: [ module.sourceCode ], + }; +} + +function shouldPreload({meta}) { + return meta && meta.preloaded; +} + +function partition(array, predicate) { + const included = []; + const excluded = []; + array.forEach(item => (predicate(item) ? included : excluded).push(item)); + return [included, excluded]; +} + +function * filter(iterator, predicate) { + for (const value of iterator) { + if (predicate(value)) { + yield value; + } + } +} + +function * subtree(moduleTransport: ModuleTransport, moduleTransportsByPath, seen = new Set()) { + seen.add(moduleTransport.id); + /* $FlowFixMe: there may not be a `meta` object */ + for (const [, {path}] of moduleTransport.meta.dependencyPairs || []) { + const dependency = moduleTransportsByPath.get(path); + if (dependency && !seen.has(dependency.id)) { + yield dependency.id; + yield * subtree(dependency, moduleTransportsByPath, seen); + } + } +} + +class ArrayMap extends Map { + get(key) { + let array = super.get(key); + if (!array) { + array = []; + this.set(key, array); + } + return array; + } +} + +function createGroups(ramGroups: Array, lazyModules) { + // build two maps that allow to lookup module data + // by path or (numeric) module id; + const byPath = new Map(); + const byId = new Map(); + lazyModules.forEach(m => { + byPath.set(m.sourcePath, m); + byId.set(m.id, m.sourcePath); + }); + + // build a map of group root IDs to an array of module IDs in the group + const result: Map> = new Map( + ramGroups + .map(modulePath => { + const root = byPath.get(modulePath); + if (!root) { + throw Error(`Group root ${modulePath} is not part of the bundle`); + } + return [ + root.id, + // `subtree` yields the IDs of all transitive dependencies of a module + /* $FlowFixMe: assumes the module is always in the Map */ + new Set(subtree(byPath.get(root.sourcePath), byPath)), + ]; + }) + ); + + if (ramGroups.length > 1) { + // build a map of all grouped module IDs to an array of group root IDs + const all = new ArrayMap(); + for (const [parent, children] of result) { + for (const module of children) { + all.get(module).push(parent); + } + } + + // find all module IDs that are part of more than one group + const doubles = filter(all, ([, parents]) => parents.length > 1); + for (const [moduleId, parents] of doubles) { + // remove them from their groups + /* $FlowFixMe: this assumes the element exists. */ + parents.forEach(p => result.get(p).delete(moduleId)); + + // print a warning for each removed module + const parentNames = parents.map(byId.get, byId); + const lastName = parentNames.pop(); + console.warn( + /* $FlowFixMe: this assumes the element exists. */ + `Module ${byId.get(moduleId)} belongs to groups ${ + parentNames.join(', ')}, and ${lastName + }. Removing it from all groups.` + ); + } + } + + return result; +} + +const isRawMappings = Array.isArray; + +module.exports = Bundle; diff --git a/packages/metro-bundler/react-packager/src/Bundler/BundleBase.js b/packages/metro-bundler/react-packager/src/Bundler/BundleBase.js new file mode 100644 index 00000000..60a7d3be --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/BundleBase.js @@ -0,0 +1,114 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const ModuleTransport = require('../lib/ModuleTransport'); + +export type FinalizeOptions = { + allowUpdates?: boolean, + runBeforeMainModule?: Array, + runMainModule?: boolean, +}; + +export type GetSourceOptions = { + inlineSourceMap?: boolean, + dev: boolean, +}; + +class BundleBase { + + _assets: Array; + _finalized: boolean; + _mainModuleId: number | void; + _modules: Array; + _source: ?string; + + constructor() { + this._finalized = false; + this._modules = []; + this._assets = []; + this._mainModuleId = undefined; + } + + isEmpty() { + return this._modules.length === 0 && this._assets.length === 0; + } + + getMainModuleId() { + return this._mainModuleId; + } + + setMainModuleId(moduleId: number) { + this._mainModuleId = moduleId; + } + + addModule(module: ModuleTransport) { + if (!(module instanceof ModuleTransport)) { + throw new Error('Expected a ModuleTransport object'); + } + + return this._modules.push(module) - 1; + } + + replaceModuleAt(index: number, module: ModuleTransport) { + if (!(module instanceof ModuleTransport)) { + throw new Error('Expeceted a ModuleTransport object'); + } + + this._modules[index] = module; + } + + getModules() { + return this._modules; + } + + getAssets() { + return this._assets; + } + + addAsset(asset: mixed) { + this._assets.push(asset); + } + + finalize(options: FinalizeOptions) { + if (!options.allowUpdates) { + Object.freeze(this._modules); + Object.freeze(this._assets); + } + + this._finalized = true; + } + + getSource(options: GetSourceOptions) { + this.assertFinalized(); + + if (this._source) { + return this._source; + } + + this._source = this._modules.map((module) => module.code).join('\n'); + return this._source; + } + + invalidateSource() { + this._source = null; + } + + assertFinalized(message?: string) { + if (!this._finalized) { + throw new Error(message || 'Bundle needs to be finalized before getting any source'); + } + } + + setRamGroups(ramGroups: Array) {} +} + +module.exports = BundleBase; diff --git a/packages/metro-bundler/react-packager/src/Bundler/HMRBundle.js b/packages/metro-bundler/react-packager/src/Bundler/HMRBundle.js new file mode 100644 index 00000000..1153b164 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/HMRBundle.js @@ -0,0 +1,55 @@ +/** + * 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'; + +const BundleBase = require('./BundleBase'); +const ModuleTransport = require('../lib/ModuleTransport'); + +class HMRBundle extends BundleBase { + constructor({sourceURLFn, sourceMappingURLFn}) { + super(); + this._sourceURLFn = sourceURLFn + this._sourceMappingURLFn = sourceMappingURLFn; + this._sourceURLs = []; + this._sourceMappingURLs = []; + } + + addModule(resolver, response, module, moduleTransport) { + const code = resolver.resolveRequires( + response, + module, + moduleTransport.code, + moduleTransport.meta.dependencyOffsets, + ); + + super.addModule(new ModuleTransport({...moduleTransport, code})); + this._sourceMappingURLs.push(this._sourceMappingURLFn(moduleTransport.sourcePath)); + this._sourceURLs.push(this._sourceURLFn(moduleTransport.sourcePath)); + return Promise.resolve(); + } + + getModulesIdsAndCode() { + return this._modules.map(module => { + return { + id: JSON.stringify(module.id), + code: module.code, + }; + }); + } + + getSourceURLs() { + return this._sourceURLs; + } + + getSourceMappingURLs() { + return this._sourceMappingURLs; + } +} + +module.exports = HMRBundle; diff --git a/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundle-test.js b/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundle-test.js new file mode 100644 index 00000000..57db9133 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundle-test.js @@ -0,0 +1,427 @@ +/** + * 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.disableAutomock(); + +const Bundle = require('../Bundle'); +const ModuleTransport = require('../../lib/ModuleTransport'); +const SourceMapGenerator = require('source-map').SourceMapGenerator; +const crypto = require('crypto'); + +describe('Bundle', () => { + var bundle; + + beforeEach(() => { + bundle = new Bundle({sourceMapUrl: 'test_url'}); + bundle.getSourceMap = jest.fn(() => { + return 'test-source-map'; + }); + }); + + describe('source bundle', () => { + it('should create a bundle and get the source', () => { + return Promise.resolve().then(() => { + return addModule({ + bundle, + code: 'transformed foo;', + sourceCode: 'source foo', + sourcePath: 'foo path', + }); + }).then(() => { + return addModule({ + bundle, + code: 'transformed bar;', + sourceCode: 'source bar', + sourcePath: 'bar path', + }); + }).then(() => { + bundle.finalize({}); + expect(bundle.getSource({dev: true})).toBe([ + 'transformed foo;', + 'transformed bar;', + '\/\/# sourceMappingURL=test_url' + ].join('\n')); + }); + }); + + it('should be ok to leave out the source map url', () => { + const otherBundle = new Bundle(); + return Promise.resolve().then(() => { + return addModule({ + bundle: otherBundle, + code: 'transformed foo;', + sourceCode: 'source foo', + sourcePath: 'foo path', + }); + }).then(() => { + return addModule({ + bundle: otherBundle, + code: 'transformed bar;', + sourceCode: 'source bar', + sourcePath: 'bar path', + }); + }).then(() => { + otherBundle.finalize({}); + expect(otherBundle.getSource({dev: true})).toBe([ + 'transformed foo;', + 'transformed bar;', + ].join('\n')); + }); + }); + + it('should create a bundle and add run module code', () => { + return Promise.resolve().then(() => { + return addModule({ + bundle, + code: 'transformed foo;', + sourceCode: 'source foo', + sourcePath: 'foo path', + }); + }).then(() => { + return addModule({ + bundle, + code: 'transformed bar;', + sourceCode: 'source bar', + sourcePath: 'bar path', + }); + }).then(() => { + bundle.setMainModuleId('foo'); + bundle.finalize({ + runBeforeMainModule: ['bar'], + runMainModule: true, + }); + expect(bundle.getSource({dev: true})).toBe([ + 'transformed foo;', + 'transformed bar;', + ';require("bar");', + ';require("foo");', + '\/\/# sourceMappingURL=test_url', + ].join('\n')); + }); + }); + + it('should insert modules in a deterministic order, independent from timing of the wrapping process', () => { + const moduleTransports = [ + createModuleTransport({name: 'module1'}), + createModuleTransport({name: 'module2'}), + createModuleTransport({name: 'module3'}), + ]; + + const resolves = {}; + const resolver = { + wrapModule({name}) { + return new Promise(resolve => resolves[name] = resolve); + } + }; + + const promise = Promise.all( + moduleTransports.map(m => bundle.addModule(resolver, null, {isPolyfill: () => false}, m))) + .then(() => { + expect(bundle.getModules()) + .toEqual(moduleTransports); + }); + + resolves.module2({code: ''}); + resolves.module3({code: ''}); + resolves.module1({code: ''}); + + return promise; + }); + }); + + describe('sourcemap bundle', () => { + it('should create sourcemap', () => { + //TODO: #15357872 add a meaningful test here + }); + + it('should combine sourcemaps', () => { + const otherBundle = new Bundle({sourceMapUrl: 'test_url'}); + + return Promise.resolve().then(() => { + return addModule({ + bundle: otherBundle, + code: 'transformed foo;\n', + sourceCode: 'source foo', + map: {name: 'sourcemap foo'}, + sourcePath: 'foo path', + }); + }).then(() => { + return addModule({ + bundle: otherBundle, + code: 'transformed bar;\n', + sourceCode: 'source bar', + map: {name: 'sourcemap bar'}, + sourcePath: 'bar path', + }); + }).then(() => { + return addModule({ + bundle: otherBundle, + code: 'image module;\nimage module;', + virtual: true, + sourceCode: 'image module;\nimage module;', + sourcePath: 'image.png', + }); + }).then(() => { + otherBundle.setMainModuleId('foo'); + otherBundle.finalize({ + runBeforeMainModule: ['InitializeCore'], + runMainModule: true, + }); + + const sourceMap = otherBundle.getSourceMap({dev: true}); + expect(sourceMap).toEqual({ + file: 'test_url', + version: 3, + sections: [ + { offset: { line: 0, column: 0 }, map: { name: 'sourcemap foo' } }, + { offset: { line: 2, column: 0 }, map: { name: 'sourcemap bar' } }, + { + offset: { + column: 0, + line: 4 + }, + map: { + file: 'image.png', + mappings: 'AAAA;AACA;', + names: [], + sources: [ 'image.png' ], + sourcesContent: ['image module;\nimage module;'], + version: 3, + } + }, + { + offset: { + column: 0, + line: 6 + }, + map: { + file: 'require-InitializeCore.js', + mappings: 'AAAA;', + names: [], + sources: [ 'require-InitializeCore.js' ], + sourcesContent: [';require("InitializeCore");'], + version: 3, + } + }, + { + offset: { + column: 0, + line: 7 + }, + map: { + file: 'require-foo.js', + mappings: 'AAAA;', + names: [], + sources: [ 'require-foo.js' ], + sourcesContent: [';require("foo");'], + version: 3, + } + }, + ], + }); + }); + }); + }); + + describe('getAssets()', () => { + it('should save and return asset objects', () => { + var p = new Bundle({sourceMapUrl: 'test_url'}); + var asset1 = {}; + var asset2 = {}; + p.addAsset(asset1); + p.addAsset(asset2); + p.finalize(); + expect(p.getAssets()).toEqual([asset1, asset2]); + }); + }); + + describe('getJSModulePaths()', () => { + it('should return module paths', () => { + var otherBundle = new Bundle({sourceMapUrl: 'test_url'}); + return Promise.resolve().then(() => { + return addModule({ + bundle: otherBundle, + code: 'transformed foo;\n', + sourceCode: 'source foo', + sourcePath: 'foo path', + }); + }).then(() => { + return addModule({ + bundle: otherBundle, + code: 'image module;\nimage module;', + virtual: true, + sourceCode: 'image module;\nimage module;', + sourcePath: 'image.png', + }); + }).then(() => { + expect(otherBundle.getJSModulePaths()).toEqual(['foo path']); + }); + }); + }); + + describe('getEtag()', function() { + it('should return an etag', function() { + var bundle = new Bundle({sourceMapUrl: 'test_url'}); + bundle.finalize({}); + var eTag = crypto.createHash('md5').update(bundle.getSource()).digest('hex'); + expect(bundle.getEtag()).toEqual(eTag); + }); + }); + + describe('main module id:', function() { + it('can save a main module ID', function() { + const id = 'arbitrary module ID'; + bundle.setMainModuleId(id); + expect(bundle.getMainModuleId()).toEqual(id); + }); + }); + + describe('random access bundle groups:', () => { + let moduleTransports; + beforeEach(() => { + moduleTransports = [ + transport('Product1', ['React', 'Relay']), + transport('React', ['ReactFoo', 'ReactBar']), + transport('ReactFoo', ['invariant']), + transport('invariant', []), + transport('ReactBar', ['cx']), + transport('cx', []), + transport('OtherFramework', ['OtherFrameworkFoo', 'OtherFrameworkBar']), + transport('OtherFrameworkFoo', ['invariant']), + transport('OtherFrameworkBar', ['crc32']), + transport('crc32', ['OtherFrameworkBar']), + ]; + }); + + it('can create a single group', () => { + bundle = createBundle([fsLocation('React')]); + const {groups} = bundle.getUnbundle(); + expect(groups).toEqual(new Map([ + [idFor('React'), new Set(['ReactFoo', 'invariant', 'ReactBar', 'cx'].map(idFor))], + ])); + }); + + it('can create two groups', () => { + bundle = createBundle([fsLocation('ReactFoo'), fsLocation('ReactBar')]); + const {groups} = bundle.getUnbundle(); + expect(groups).toEqual(new Map([ + [idFor('ReactFoo'), new Set([idFor('invariant')])], + [idFor('ReactBar'), new Set([idFor('cx')])], + ])); + }); + + it('can handle circular dependencies', () => { + bundle = createBundle([fsLocation('OtherFramework')]); + const {groups} = bundle.getUnbundle(); + expect(groups).toEqual(new Map([[ + idFor('OtherFramework'), + new Set(['OtherFrameworkFoo', 'invariant', 'OtherFrameworkBar', 'crc32'].map(idFor)), + ]])); + }); + + it('omits modules that are contained by more than one group', () => { + bundle = createBundle([fsLocation('React'), fsLocation('OtherFramework')]); + const {groups} = bundle.getUnbundle(); + expect(groups).toEqual(new Map([ + [idFor('React'), + new Set(['ReactFoo', 'ReactBar', 'cx'].map(idFor))], + [idFor('OtherFramework'), + new Set(['OtherFrameworkFoo', 'OtherFrameworkBar', 'crc32'].map(idFor))], + ])); + }); + + it('ignores missing dependencies', () => { + bundle = createBundle([fsLocation('Product1')]); + const {groups} = bundle.getUnbundle(); + expect(groups).toEqual(new Map([[ + idFor('Product1'), + new Set(['React', 'ReactFoo', 'invariant', 'ReactBar', 'cx'].map(idFor)) + ]])); + }); + + it('throws for group roots that do not exist', () => { + bundle = createBundle([fsLocation('DoesNotExist')]); + expect(() => { + const {groups} = bundle.getUnbundle(); //eslint-disable-line no-unused-vars + }).toThrow(new Error(`Group root ${fsLocation('DoesNotExist')} is not part of the bundle`)); + }); + + function idFor(name) { + const {map} = idFor; + if (!map) { + idFor.map = new Map([[name, 0]]); + idFor.next = 1; + return 0; + } + + if (map.has(name)) { + return map.get(name); + } + + const id = idFor.next++; + map.set(name, id); + return id; + } + function createBundle(ramGroups, options = {}) { + const b = new Bundle(Object.assign(options, {ramGroups})); + moduleTransports.forEach(t => addModule({bundle: b, ...t})); + b.finalize(); + return b; + } + function fsLocation(name) { + return `/fs/${name}.js`; + } + function module(name) { + return {path: fsLocation(name)}; + } + function transport(name, deps) { + return createModuleTransport({ + name, + id: idFor(name), + sourcePath: fsLocation(name), + meta: {dependencyPairs: deps.map(d => [d, module(d)])}, + }); + } + }); +}); + +function resolverFor(code, map) { + return { + wrapModule: () => Promise.resolve({code, map}), + }; +} + +function addModule({bundle, code, sourceCode, sourcePath, map, virtual, polyfill, meta, id = ''}) { + return bundle.addModule( + resolverFor(code, map), + null, + {isPolyfill: () => polyfill}, + createModuleTransport({ + code, + sourceCode, + sourcePath, + id, + map, + meta, + virtual, + polyfill, + }), + ); +} + +function createModuleTransport(data) { + return new ModuleTransport({ + code: '', + sourceCode: '', + sourcePath: '', + id: 'id' in data ? data.id : '', + ...data, + }); +} diff --git a/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundler-test.js b/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundler-test.js new file mode 100644 index 00000000..5b735de9 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/__tests__/Bundler-test.js @@ -0,0 +1,351 @@ +/** + * 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.disableAutomock(); + +jest + .setMock('worker-farm', () => () => undefined) + .setMock('uglify-js') + .mock('image-size') + .mock('fs') + .mock('assert') + .mock('progress') + .mock('../../node-haste') + .mock('../../JSTransformer') + .mock('../../lib/declareOpts') + .mock('../../Resolver') + .mock('../Bundle') + .mock('../HMRBundle') + .mock('../../Logger') + .mock('../../lib/declareOpts'); + +var Bundler = require('../'); +var Resolver = require('../../Resolver'); +var defaults = require('../../../../defaults'); +var sizeOf = require('image-size'); +var fs = require('fs'); + +var commonOptions = { + allowBundleUpdates: false, + assetExts: defaults.assetExts, + cacheVersion: 'smth', + extraNodeModules: {}, + platforms: defaults.platforms, + resetCache: false, + watch: false, +}; + +describe('Bundler', function() { + + function createModule({ + path, + id, + dependencies, + isAsset, + isJSON, + isPolyfill, + resolution, + }) { + return { + path, + resolution, + getDependencies: () => Promise.resolve(dependencies), + getName: () => Promise.resolve(id), + isJSON: () => isJSON, + isAsset: () => isAsset, + isPolyfill: () => isPolyfill, + read: () => ({ + code: 'arbitrary', + source: 'arbitrary', + }), + }; + } + + var getDependencies; + var getModuleSystemDependencies; + var bundler; + var assetServer; + var modules; + var projectRoots; + + beforeEach(function() { + getDependencies = jest.fn(); + getModuleSystemDependencies = jest.fn(); + projectRoots = ['/root']; + + Resolver.mockImplementation(function() { + return { + getDependencies: getDependencies, + getModuleSystemDependencies: getModuleSystemDependencies, + }; + }); + + fs.statSync.mockImplementation(function() { + return { + isDirectory: () => true + }; + }); + + fs.readFile.mockImplementation(function(file, callback) { + callback(null, '{"json":true}'); + }); + + assetServer = { + getAssetData: jest.fn(), + }; + + bundler = new Bundler({ + ...commonOptions, + projectRoots, + assetServer: assetServer, + }); + + modules = [ + createModule({id: 'foo', path: '/root/foo.js', dependencies: []}), + createModule({id: 'bar', path: '/root/bar.js', dependencies: []}), + createModule({ + id: 'new_image.png', + path: '/root/img/new_image.png', + isAsset: true, + resolution: 2, + dependencies: [] + }), + createModule({ + id: 'package/file.json', + path: '/root/file.json', + isJSON: true, + dependencies: [], + }), + ]; + + getDependencies.mockImplementation((main, options, transformOptions) => + Promise.resolve({ + mainModuleId: 'foo', + dependencies: modules, + transformOptions, + getModuleId: () => 123, + getResolvedDependencyPairs: () => [], + }) + ); + + getModuleSystemDependencies.mockImplementation(function() { + return []; + }); + + sizeOf.mockImplementation(function(path, cb) { + cb(null, { width: 50, height: 100 }); + }); + }); + + it('create a bundle', function() { + assetServer.getAssetData.mockImplementation(() => { + return Promise.resolve({ + scales: [1,2,3], + files: [ + '/root/img/img.png', + '/root/img/img@2x.png', + '/root/img/img@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + }); + }); + + return bundler.bundle({ + entryFile: '/root/foo.js', + runBeforeMainModule: [], + runModule: true, + sourceMapUrl: 'source_map_url', + }).then(bundle => { + const ithAddedModule = (i) => bundle.addModule.mock.calls[i][2].path; + + expect(ithAddedModule(0)).toEqual('/root/foo.js'); + expect(ithAddedModule(1)).toEqual('/root/bar.js'); + expect(ithAddedModule(2)).toEqual('/root/img/new_image.png'); + expect(ithAddedModule(3)).toEqual('/root/file.json'); + + expect(bundle.finalize.mock.calls[0]).toEqual([{ + runMainModule: true, + runBeforeMainModule: [], + allowUpdates: false, + }]); + + expect(bundle.addAsset.mock.calls[0]).toEqual([{ + __packager_asset: true, + fileSystemLocation: '/root/img', + httpServerLocation: '/assets/img', + width: 50, + height: 100, + scales: [1, 2, 3], + files: [ + '/root/img/img.png', + '/root/img/img@2x.png', + '/root/img/img@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + }]); + + // TODO(amasad) This fails with 0 != 5 in OSS + //expect(ProgressBar.prototype.tick.mock.calls.length).toEqual(modules.length); + }); + }); + + it('loads and runs asset plugins', function() { + jest.mock('mockPlugin1', () => { + return asset => { + asset.extraReverseHash = asset.hash.split('').reverse().join(''); + return asset; + }; + }, {virtual: true}); + + jest.mock('asyncMockPlugin2', () => { + return asset => { + expect(asset.extraReverseHash).toBeDefined(); + return new Promise((resolve) => { + asset.extraPixelCount = asset.width * asset.height; + resolve(asset); + }); + }; + }, {virtual: true}); + + const mockAsset = { + scales: [1,2,3], + files: [ + '/root/img/img.png', + '/root/img/img@2x.png', + '/root/img/img@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + }; + assetServer.getAssetData.mockImplementation(() => Promise.resolve(mockAsset)); + + return bundler.bundle({ + entryFile: '/root/foo.js', + runBeforeMainModule: [], + runModule: true, + sourceMapUrl: 'source_map_url', + assetPlugins: ['mockPlugin1', 'asyncMockPlugin2'], + }).then(bundle => { + expect(bundle.addAsset.mock.calls[0]).toEqual([{ + __packager_asset: true, + fileSystemLocation: '/root/img', + httpServerLocation: '/assets/img', + width: 50, + height: 100, + scales: [1, 2, 3], + files: [ + '/root/img/img.png', + '/root/img/img@2x.png', + '/root/img/img@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + extraReverseHash: 'hsah a ma i', + extraPixelCount: 5000, + }]); + }); + }); + + it('gets the list of dependencies from the resolver', function() { + const entryFile = '/root/foo.js'; + return bundler.getDependencies({entryFile, recursive: true}).then(() => + // jest calledWith does not support jasmine.any + expect(getDependencies.mock.calls[0].slice(0, -2)).toEqual([ + '/root/foo.js', + { dev: true, recursive: true }, + { minify: false, + dev: true, + transform: { + dev: true, + hot: false, + generateSourceMaps: false, + projectRoots, + } + }, + ]) + ); + }); + + it('allows overriding the platforms array', () => { + expect(bundler._opts.platforms).toEqual(['ios', 'android', 'windows', 'web']); + const b = new Bundler({ + ...commonOptions, + projectRoots, + assetServer: assetServer, + platforms: ['android', 'vr'], + }); + expect(b._opts.platforms).toEqual(['android', 'vr']); + }); + + describe('getOrderedDependencyPaths', () => { + beforeEach(() => { + assetServer.getAssetData.mockImplementation(function(relPath) { + if (relPath === 'img/new_image.png') { + return Promise.resolve({ + scales: [1,2,3], + files: [ + '/root/img/new_image.png', + '/root/img/new_image@2x.png', + '/root/img/new_image@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + }); + } else if (relPath === 'img/new_image2.png') { + return Promise.resolve({ + scales: [1,2,3], + files: [ + '/root/img/new_image2.png', + '/root/img/new_image2@2x.png', + '/root/img/new_image2@3x.png', + ], + hash: 'i am a hash', + name: 'img', + type: 'png', + }); + } + + throw new Error('unknown image ' + relPath); + }); + }); + + it('should get the concrete list of all dependency files', () => { + modules.push( + createModule({ + id: 'new_image2.png', + path: '/root/img/new_image2.png', + isAsset: true, + resolution: 2, + dependencies: [] + }), + ); + + return bundler.getOrderedDependencyPaths('/root/foo.js', true) + .then((paths) => expect(paths).toEqual([ + '/root/foo.js', + '/root/bar.js', + '/root/img/new_image.png', + '/root/img/new_image@2x.png', + '/root/img/new_image@3x.png', + '/root/file.json', + '/root/img/new_image2.png', + '/root/img/new_image2@2x.png', + '/root/img/new_image2@3x.png', + ])); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/Bundler/index.js b/packages/metro-bundler/react-packager/src/Bundler/index.js new file mode 100644 index 00000000..dc701656 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/index.js @@ -0,0 +1,772 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const debug = require('debug')('RNP:Bundler'); +const fs = require('fs'); +const Cache = require('../node-haste').Cache; +const Transformer = require('../JSTransformer'); +const Resolver = require('../Resolver'); +const Bundle = require('./Bundle'); +const HMRBundle = require('./HMRBundle'); +const ModuleTransport = require('../lib/ModuleTransport'); +const imageSize = require('image-size'); +const path = require('path'); +const version = require('../../../package.json').version; +const denodeify = require('denodeify'); + +const { + sep: pathSeparator, + join: joinPath, + relative: relativePath, + dirname: pathDirname, + extname, +} = require('path'); + +import type AssetServer from '../AssetServer'; +import type Module from '../node-haste/Module'; +import type ResolutionResponse from '../node-haste/DependencyGraph/ResolutionResponse'; +import type {Options as JSTransformerOptions, TransformOptions} from '../JSTransformer/worker/worker'; +import type {Reporter} from '../lib/reporting'; +import type GlobalTransformCache from '../lib/GlobalTransformCache'; + +export type GetTransformOptions = ( + mainModuleName: string, + options: {}, + getDependencies: string => Promise>, +) => {} | Promise<{}>; + +const sizeOf = denodeify(imageSize); + +const noop = () => {}; + +const { + createActionStartEntry, + createActionEndEntry, + log, +} = require('../Logger'); + +const assetPropertyBlacklist = new Set([ + 'files', + 'fileSystemLocation', + 'path', +]); + +type Options = { + allowBundleUpdates: boolean, + assetExts: Array, + assetServer: AssetServer, + blacklistRE?: RegExp, + cacheVersion: string, + extraNodeModules: {}, + getTransformOptions?: GetTransformOptions, + globalTransformCache: ?GlobalTransformCache, + moduleFormat: string, + platforms: Array, + polyfillModuleNames: Array, + projectRoots: Array, + providesModuleNodeModules?: Array, + reporter: Reporter, + resetCache: boolean, + transformModulePath?: string, + transformTimeoutInterval: ?number, + watch: boolean, +}; + +class Bundler { + + _opts: Options; + _getModuleId: (opts: Module) => number; + _cache: Cache; + _transformer: Transformer; + _resolver: Resolver; + _projectRoots: Array; + _assetServer: AssetServer; + _getTransformOptions: void | GetTransformOptions; + + constructor(opts: Options) { + this._opts = opts; + + opts.projectRoots.forEach(verifyRootExists); + + let transformModuleHash; + try { + /* $FlowFixMe: if transformModulePath is null it'll just be caught */ + const transformModuleStr = fs.readFileSync(opts.transformModulePath); + transformModuleHash = + crypto.createHash('sha1').update(transformModuleStr).digest('hex'); + } catch (error) { + transformModuleHash = ''; + } + + const stableProjectRoots = opts.projectRoots.map(p => { + return path.relative(path.join(__dirname, '../../../..'), p); + }); + + const cacheKeyParts = [ + 'react-packager-cache', + version, + opts.cacheVersion, + stableProjectRoots.join(',').split(pathSeparator).join('-'), + transformModuleHash, + ]; + + this._getModuleId = createModuleIdFactory(); + + if (opts.transformModulePath) { + /* $FlowFixMe: dynamic requires prevent static typing :'( */ + const transformer = require(opts.transformModulePath); + if (typeof transformer.cacheKey !== 'undefined') { + cacheKeyParts.push(transformer.cacheKey); + } + } + + const transformCacheKey = crypto.createHash('sha1').update( + cacheKeyParts.join('$'), + ).digest('hex'); + + debug(`Using transform cache key "${transformCacheKey}"`); + + this._cache = new Cache({ + resetCache: opts.resetCache, + cacheKey: transformCacheKey, + }); + + this._transformer = new Transformer({ + transformModulePath: opts.transformModulePath, + }); + + this._resolver = new Resolver({ + assetExts: opts.assetExts, + blacklistRE: opts.blacklistRE, + cache: this._cache, + extraNodeModules: opts.extraNodeModules, + globalTransformCache: opts.globalTransformCache, + minifyCode: this._transformer.minify, + moduleFormat: opts.moduleFormat, + platforms: opts.platforms, + polyfillModuleNames: opts.polyfillModuleNames, + projectRoots: opts.projectRoots, + providesModuleNodeModules: opts.providesModuleNodeModules, + reporter: opts.reporter, + resetCache: opts.resetCache, + transformCacheKey, + transformCode: + (module, code, transformCodeOptions) => this._transformer.transformFile( + module.path, + code, + transformCodeOptions, + ), + watch: opts.watch, + }); + + this._projectRoots = opts.projectRoots; + this._assetServer = opts.assetServer; + + this._getTransformOptions = opts.getTransformOptions; + } + + end() { + this._transformer.kill(); + return Promise.all([ + this._cache.end(), + this.getResolver().getDependencyGraph().getWatcher().end(), + ]); + } + + bundle(options: { + dev: boolean, + minify: boolean, + unbundle: boolean, + sourceMapUrl: string, + }) { + const {dev, minify, unbundle} = options; + const moduleSystemDeps = + this._resolver.getModuleSystemDependencies({dev, unbundle}); + return this._bundle({ + ...options, + bundle: new Bundle({dev, minify, sourceMapUrl: options.sourceMapUrl}), + moduleSystemDeps, + }); + } + + _sourceHMRURL(platform, hmrpath) { + return this._hmrURL( + '', + platform, + 'bundle', + hmrpath, + ); + } + + _sourceMappingHMRURL(platform, hmrpath) { + // Chrome expects `sourceURL` when eval'ing code + return this._hmrURL( + '\/\/# sourceURL=', + platform, + 'map', + hmrpath, + ); + } + + _hmrURL(prefix, platform, extensionOverride, filePath) { + const matchingRoot = this._projectRoots.find(root => filePath.startsWith(root)); + + if (!matchingRoot) { + throw new Error('No matching project root for ', filePath); + } + + // Replaces '\' with '/' for Windows paths. + if (pathSeparator === '\\') { + filePath = filePath.replace(/\\/g, '/'); + } + + const extensionStart = filePath.lastIndexOf('.'); + const resource = filePath.substring( + matchingRoot.length, + extensionStart !== -1 ? extensionStart : undefined, + ); + + return ( + prefix + resource + + '.' + extensionOverride + '?' + + 'platform=' + platform + '&runModule=false&entryModuleOnly=true&hot=true' + ); + } + + hmrBundle(options: {platform: ?string}, host: string, port: number) { + return this._bundle({ + ...options, + bundle: new HMRBundle({ + sourceURLFn: this._sourceHMRURL.bind(this, options.platform), + sourceMappingURLFn: this._sourceMappingHMRURL.bind( + this, + options.platform, + ), + }), + hot: true, + dev: true, + }); + } + + _bundle({ + bundle, + entryFile, + runModule: runMainModule, + runBeforeMainModule, + dev, + minify, + platform, + moduleSystemDeps = [], + hot, + unbundle, + entryModuleOnly, + resolutionResponse, + isolateModuleIDs, + generateSourceMaps, + assetPlugins, + onProgress, + }) { + const onResolutionResponse = (response: ResolutionResponse) => { + /* $FlowFixMe: looks like ResolutionResponse is monkey-patched + * with `getModuleId`. */ + bundle.setMainModuleId(response.getModuleId(getMainModule(response))); + if (entryModuleOnly) { + response.dependencies = response.dependencies.filter(module => + module.path.endsWith(entryFile) + ); + } else { + response.dependencies = moduleSystemDeps.concat(response.dependencies); + } + }; + const finalizeBundle = ({bundle: finalBundle, transformedModules, response, modulesByName}: { + bundle: Bundle, + transformedModules: Array<{module: Module, transformed: ModuleTransport}>, + response: ResolutionResponse, + modulesByName: {[name: string]: Module}, + }) => + Promise.all( + transformedModules.map(({module, transformed}) => + finalBundle.addModule(this._resolver, response, module, transformed) + ) + ).then(() => { + const runBeforeMainModuleIds = Array.isArray(runBeforeMainModule) + ? runBeforeMainModule + .map(name => modulesByName[name]) + .filter(Boolean) + .map(response.getModuleId) + : undefined; + + finalBundle.finalize({ + runMainModule, + runBeforeMainModule: runBeforeMainModuleIds, + allowUpdates: this._opts.allowBundleUpdates, + }); + return finalBundle; + }); + + return this._buildBundle({ + entryFile, + dev, + minify, + platform, + bundle, + hot, + unbundle, + resolutionResponse, + onResolutionResponse, + finalizeBundle, + isolateModuleIDs, + generateSourceMaps, + assetPlugins, + onProgress, + }); + } + + _buildBundle({ + entryFile, + dev, + minify, + platform, + bundle, + hot, + unbundle, + resolutionResponse, + isolateModuleIDs, + generateSourceMaps, + assetPlugins, + onResolutionResponse = noop, + onModuleTransformed = noop, + finalizeBundle = noop, + onProgress = noop, + }: *) { + const transformingFilesLogEntry = + log(createActionStartEntry({ + action_name: 'Transforming files', + entry_point: entryFile, + environment: dev ? 'dev' : 'prod', + })); + + const modulesByName = Object.create(null); + + if (!resolutionResponse) { + resolutionResponse = this.getDependencies({ + entryFile, + dev, + platform, + hot, + onProgress, + minify, + isolateModuleIDs, + generateSourceMaps: unbundle || minify || generateSourceMaps, + }); + } + + return Promise.resolve(resolutionResponse).then(response => { + bundle.setRamGroups(response.transformOptions.transform.ramGroups); + + log(createActionEndEntry(transformingFilesLogEntry)); + onResolutionResponse(response); + + // get entry file complete path (`entryFile` is relative to roots) + let entryFilePath; + if (response.dependencies.length > 1) { // skip HMR requests + const numModuleSystemDependencies = + this._resolver.getModuleSystemDependencies({dev, unbundle}).length; + + const dependencyIndex = + (response.numPrependedDependencies || 0) + numModuleSystemDependencies; + + if (dependencyIndex in response.dependencies) { + entryFilePath = response.dependencies[dependencyIndex].path; + } + } + + const toModuleTransport = module => + this._toModuleTransport({ + module, + bundle, + entryFilePath, + assetPlugins, + transformOptions: response.transformOptions, + /* $FlowFixMe: `getModuleId` is monkey-patched */ + getModuleId: (response.getModuleId: () => number), + dependencyPairs: response.getResolvedDependencyPairs(module), + }).then(transformed => { + modulesByName[transformed.name] = module; + onModuleTransformed({ + module, + response, + bundle, + transformed, + }); + return {module, transformed}; + }); + + return Promise.all(response.dependencies.map(toModuleTransport)) + .then(transformedModules => + Promise.resolve( + finalizeBundle({bundle, transformedModules, response, modulesByName}) + ).then(() => bundle) + ); + }); + } + + invalidateFile(filePath: string) { + this._cache.invalidate(filePath); + } + + getShallowDependencies({ + entryFile, + platform, + dev = true, + minify = !dev, + hot = false, + generateSourceMaps = false, + }: { + entryFile: string, + platform: string, + dev?: boolean, + minify?: boolean, + hot?: boolean, + generateSourceMaps?: boolean, + }) { + return this.getTransformOptions( + entryFile, + { + dev, + platform, + hot, + generateSourceMaps, + projectRoots: this._projectRoots, + }, + ).then(transformSpecificOptions => { + const transformOptions = { + minify, + dev, + platform, + transform: transformSpecificOptions, + }; + + return this._resolver.getShallowDependencies(entryFile, transformOptions); + }); + } + + getModuleForPath(entryFile: string) { + return this._resolver.getModuleForPath(entryFile); + } + + getDependencies({ + entryFile, + platform, + dev = true, + minify = !dev, + hot = false, + recursive = true, + generateSourceMaps = false, + isolateModuleIDs = false, + onProgress, + }: { + entryFile: string, + platform: string, + dev?: boolean, + minify?: boolean, + hot?: boolean, + recursive?: boolean, + generateSourceMaps?: boolean, + isolateModuleIDs?: boolean, + onProgress?: ?(finishedModules: number, totalModules: number) => mixed, + }) { + return this.getTransformOptions( + entryFile, + { + dev, + platform, + hot, + generateSourceMaps, + projectRoots: this._projectRoots, + }, + ).then(transformSpecificOptions => { + const transformOptions = { + minify, + dev, + platform, + transform: transformSpecificOptions, + }; + + return this._resolver.getDependencies( + entryFile, + {dev, platform, recursive}, + transformOptions, + onProgress, + isolateModuleIDs ? createModuleIdFactory() : this._getModuleId, + ); + }); + } + + getOrderedDependencyPaths({ entryFile, dev, platform }: { + entryFile: string, + dev: boolean, + platform: string, + }) { + return this.getDependencies({entryFile, dev, platform}).then( + ({ dependencies }) => { + const ret = []; + const promises = []; + const placeHolder = {}; + dependencies.forEach(dep => { + if (dep.isAsset()) { + const relPath = getPathRelativeToRoot( + this._projectRoots, + dep.path + ); + promises.push( + this._assetServer.getAssetData(relPath, platform) + ); + ret.push(placeHolder); + } else { + ret.push(dep.path); + } + }); + + return Promise.all(promises).then(assetsData => { + assetsData.forEach(({ files }) => { + const index = ret.indexOf(placeHolder); + ret.splice(index, 1, ...files); + }); + return ret; + }); + } + ); + } + + _toModuleTransport({ + module, + bundle, + entryFilePath, + transformOptions, + getModuleId, + dependencyPairs, + assetPlugins, + }: { + module: Module, + bundle: Bundle, + entryFilePath: string, + transformOptions: JSTransformerOptions, + getModuleId: () => number, + dependencyPairs: Array<[mixed, {path: string}]>, + assetPlugins: Array, + }): Promise { + let moduleTransport; + const moduleId = getModuleId(module); + + if (module.isAsset()) { + moduleTransport = this._generateAssetModule( + bundle, module, moduleId, assetPlugins, transformOptions.platform); + } + + if (moduleTransport) { + return Promise.resolve(moduleTransport); + } + + return Promise.all([ + module.getName(), + module.read(transformOptions), + ]).then(( + [name, {code, dependencies, dependencyOffsets, map, source}] + ) => { + const {preloadedModules} = transformOptions.transform; + const preloaded = + module.path === entryFilePath || + module.isPolyfill() || + preloadedModules && preloadedModules.hasOwnProperty(module.path); + + return new ModuleTransport({ + name, + id: moduleId, + code, + map, + meta: {dependencies, dependencyOffsets, preloaded, dependencyPairs}, + sourceCode: source, + sourcePath: module.path + }); + }); + } + + _generateAssetObjAndCode(module, assetPlugins, platform: ?string = null) { + const relPath = getPathRelativeToRoot(this._projectRoots, module.path); + var assetUrlPath = joinPath('/assets', pathDirname(relPath)); + + // On Windows, change backslashes to slashes to get proper URL path from file path. + if (pathSeparator === '\\') { + assetUrlPath = assetUrlPath.replace(/\\/g, '/'); + } + + // Test extension against all types supported by image-size module. + // If it's not one of these, we won't treat it as an image. + const isImage = [ + 'png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp', 'psd', 'svg', 'tiff' + ].indexOf(extname(module.path).slice(1)) !== -1; + + return this._assetServer.getAssetData(relPath, platform).then((assetData) => { + return Promise.all([isImage ? sizeOf(assetData.files[0]) : null, assetData]); + }).then((res) => { + const dimensions = res[0]; + const assetData = res[1]; + const scale = assetData.scales[0]; + const asset = { + __packager_asset: true, + fileSystemLocation: pathDirname(module.path), + httpServerLocation: assetUrlPath, + width: dimensions ? dimensions.width / scale : undefined, + height: dimensions ? dimensions.height / scale : undefined, + scales: assetData.scales, + files: assetData.files, + hash: assetData.hash, + name: assetData.name, + type: assetData.type, + }; + + return this._applyAssetPlugins(assetPlugins, asset); + }).then((asset) => { + const json = JSON.stringify(filterObject(asset, assetPropertyBlacklist)); + const assetRegistryPath = 'react-native/Libraries/Image/AssetRegistry'; + const code = + `module.exports = require(${JSON.stringify(assetRegistryPath)}).registerAsset(${json});`; + const dependencies = [assetRegistryPath]; + const dependencyOffsets = [code.indexOf(assetRegistryPath) - 1]; + + return { + asset, + code, + meta: {dependencies, dependencyOffsets} + }; + }); + } + + _applyAssetPlugins(assetPlugins, asset) { + if (!assetPlugins.length) { + return asset; + } + + const [currentAssetPlugin, ...remainingAssetPlugins] = assetPlugins; + /* $FlowFixMe: dynamic requires prevent static typing :'( */ + const assetPluginFunction = require(currentAssetPlugin); + const result = assetPluginFunction(asset); + + // If the plugin was an async function, wait for it to fulfill before + // applying the remaining plugins + if (typeof result.then === 'function') { + return result.then(resultAsset => + this._applyAssetPlugins(remainingAssetPlugins, resultAsset) + ); + } else { + return this._applyAssetPlugins(remainingAssetPlugins, result); + } + } + + _generateAssetModule( + bundle: Bundle, + module: Module, + moduleId: number, + assetPlugins: Array = [], + platform: ?string = null, + ) { + return Promise.all([ + module.getName(), + this._generateAssetObjAndCode(module, assetPlugins, platform), + ]).then(([name, {asset, code, meta}]) => { + bundle.addAsset(asset); + return new ModuleTransport({ + name, + id: moduleId, + code, + meta: meta, + sourceCode: code, + sourcePath: module.path, + virtual: true, + }); + }); + } + + getTransformOptions( + mainModuleName: string, + options: { + dev?: boolean, + generateSourceMaps?: boolean, + hot?: boolean, + platform: string, + projectRoots: Array, + }, + ): Promise { + const getDependencies = (entryFile: string) => + this.getDependencies({...options, entryFile}) + .then(r => r.dependencies.map(d => d.path)); + const extraOptions = this._getTransformOptions + ? this._getTransformOptions(mainModuleName, options, getDependencies) + : null; + return Promise.resolve(extraOptions) + .then(extraOpts => { + return {...options, ...extraOpts}; + }); + } + + getResolver() { + return this._resolver; + } +} + +function getPathRelativeToRoot(roots, absPath) { + for (let i = 0; i < roots.length; i++) { + const relPath = relativePath(roots[i], absPath); + if (relPath[0] !== '.') { + return relPath; + } + } + + throw new Error( + 'Expected root module to be relative to one of the project roots' + ); +} + +function verifyRootExists(root) { + // Verify that the root exists. + assert(fs.statSync(root).isDirectory(), 'Root has to be a valid directory'); +} + +function createModuleIdFactory() { + const fileToIdMap = Object.create(null); + let nextId = 0; + return ({path: modulePath}) => { + if (!(modulePath in fileToIdMap)) { + fileToIdMap[modulePath] = nextId; + nextId += 1; + } + return fileToIdMap[modulePath]; + }; +} + +function getMainModule({dependencies, numPrependedDependencies = 0}) { + return dependencies[numPrependedDependencies]; +} + +function filterObject(object, blacklist) { + const copied = Object.assign({}, object); + for (const key of blacklist) { + delete copied[key]; + } + return copied; +} + +module.exports = Bundler; diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/B64Builder.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/B64Builder.js new file mode 100644 index 00000000..f8de0870 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/B64Builder.js @@ -0,0 +1,108 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const encode = require('./encode'); + +const MAX_SEGMENT_LENGTH = 7; +const ONE_MEG = 1024 * 1024; +const COMMA = 0x2c; +const SEMICOLON = 0x3b; + +/** + * Efficient builder for base64 VLQ mappings strings. + * + * This class uses a buffer that is preallocated with one megabyte and is + * reallocated dynamically as needed, doubling its size. + * + * Encoding never creates any complex value types (strings, objects), and only + * writes character values to the buffer. + * + * For details about source map terminology and specification, check + * https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit + */ +class B64Builder { + buffer: Buffer; + pos: number; + hasSegment: boolean; + + constructor() { + this.buffer = new Buffer(ONE_MEG); + this.pos = 0; + this.hasSegment = false; + } + + /** + * Adds `n` markers for generated lines to the mappings. + */ + markLines(n: number) { + if (n < 1) { + return this; + } + this.hasSegment = false; + if (this.pos + n >= this.buffer.length) { + this._realloc(); + } + while (n--) { + this.buffer[this.pos++] = SEMICOLON; + } + return this; + } + + /** + * Starts a segment at the specified column offset in the current line. + */ + startSegment(column: number) { + if (this.hasSegment) { + this._writeByte(COMMA); + } else { + this.hasSegment = true; + } + + this.append(column); + return this; + } + + /** + * Appends a single number to the mappings. + */ + append(value: number) { + if (this.pos + MAX_SEGMENT_LENGTH >= this.buffer.length) { + this._realloc(); + } + + this.pos = encode(value, this.buffer, this.pos); + return this; + } + + /** + * Returns the string representation of the mappings. + */ + toString() { + return this.buffer.toString('ascii', 0, this.pos); + } + + _writeByte(byte) { + if (this.pos === this.buffer.length) { + this._realloc(); + } + this.buffer[this.pos++] = byte; + } + + _realloc() { + const {buffer} = this; + this.buffer = new Buffer(buffer.length * 2); + buffer.copy(this.buffer); + } +} + +module.exports = B64Builder; diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/Generator.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/Generator.js new file mode 100644 index 00000000..35427c8b --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/Generator.js @@ -0,0 +1,195 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const B64Builder = require('./B64Builder'); + +import type {SourceMap} from 'babel-core'; + +/** + * Generates a source map from raw mappings. + * + * Raw mappings are a set of 2, 4, or five elements: + * + * - line and column number in the generated source + * - line and column number in the original source + * - symbol name in the original source + * + * Mappings have to be passed in the order appearance in the generated source. + */ +class Generator { + builder: B64Builder; + last: {| + generatedColumn: number, + generatedLine: number, + name: number, + source: number, + sourceColumn: number, + sourceLine: number, + |}; + names: IndexedSet; + source: number; + sources: Array; + sourcesContent: Array; + + constructor() { + this.builder = new B64Builder(); + this.last = { + generatedColumn: 0, + generatedLine: 1, // lines are passed in 1-indexed + name: 0, + source: 0, + sourceColumn: 0, + sourceLine: 1, + }; + this.names = new IndexedSet(); + this.source = -1; + this.sources = []; + this.sourcesContent = []; + } + + /** + * Mark the beginning of a new source file. + */ + startFile(file: string, code: string) { + this.source = this.sources.push(file) - 1; + this.sourcesContent.push(code); + } + + /** + * Mark the end of the current source file + */ + endFile() { + this.source = -1; + } + + /** + * Adds a mapping for generated code without a corresponding source location. + */ + addSimpleMapping(generatedLine: number, generatedColumn: number): void { + const last = this.last; + if (this.source === -1 || + generatedLine === last.generatedLine && + generatedColumn < last.generatedColumn || + generatedLine < last.generatedLine) { + const msg = this.source === -1 + ? 'Cannot add mapping before starting a file with `addFile()`' + : 'Mapping is for a position preceding an earlier mapping'; + throw new Error(msg); + } + + if (generatedLine > last.generatedLine) { + this.builder.markLines(generatedLine - last.generatedLine); + last.generatedLine = generatedLine; + last.generatedColumn = 0; + } + + this.builder.startSegment(generatedColumn - last.generatedColumn); + last.generatedColumn = generatedColumn; + } + + /** + * Adds a mapping for generated code with a corresponding source location. + */ + addSourceMapping( + generatedLine: number, + generatedColumn: number, + sourceLine: number, + sourceColumn: number, + ): void { + this.addSimpleMapping(generatedLine, generatedColumn); + + const last = this.last; + this.builder + .append(this.source - last.source) + .append(sourceLine - last.sourceLine) + .append(sourceColumn - last.sourceColumn); + + last.source = this.source; + last.sourceColumn = sourceColumn; + last.sourceLine = sourceLine; + } + + /** + * Adds a mapping for code with a corresponding source location + symbol name. + */ + addNamedSourceMapping( + generatedLine: number, + generatedColumn: number, + sourceLine: number, + sourceColumn: number, + name: string, + ): void { + this.addSourceMapping( + generatedLine, generatedColumn, sourceLine, sourceColumn); + + const last = this.last; + const nameIndex = this.names.indexFor(name); + this.builder.append(nameIndex - last.name); + last.name = nameIndex; + } + + /** + * Return the source map as object. + */ + toMap(file?: string): SourceMap { + return { + version: 3, + file, + sources: this.sources.slice(), + sourcesContent: this.sourcesContent.slice(), + names: this.names.items(), + mappings: this.builder.toString(), + }; + } + + /** + * Return the source map as string. + * + * This is ~2.5x faster than calling `JSON.stringify(generator.toMap())` + */ + toString(file?: string): string { + return ('{' + + '"version":3,' + + (file ? `"file":${JSON.stringify(file)},` : '') + + `"sources":${JSON.stringify(this.sources)},` + + `"sourcesContent":${JSON.stringify(this.sourcesContent)},` + + `"names":${JSON.stringify(this.names.items())},` + + `"mappings":"${this.builder.toString()}"` + + '}'); + } +} + +class IndexedSet { + map: Map; + nextIndex: number; + + constructor() { + this.map = new Map(); + this.nextIndex = 0; + } + + indexFor(x: string) { + let index = this.map.get(x); + if (index == null) { + index = this.nextIndex++; + this.map.set(x, index); + } + return index; + } + + items() { + return Array.from(this.map.keys()); + } +} + +module.exports = Generator; diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/B64Builder-test.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/B64Builder-test.js new file mode 100644 index 00000000..4f62614f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/B64Builder-test.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2017-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.disableAutomock(); + +const B64Builder = require('../B64Builder'); + +let builder; +beforeEach(() => { + builder = new B64Builder(); +}); + +it('exposes a fluent interface', () => { + expect(builder.markLines(0)).toBe(builder); + expect(builder.markLines(3)).toBe(builder); + expect(builder.startSegment()).toBe(builder); + expect(builder.append(4)).toBe(builder); +}); + +it('can create an empty string', () => { + expect(builder.toString()).toEqual(''); +}); + +it('can mark a new line in the generated code', () => { + builder.markLines(1); + expect(builder.toString()).toEqual(';'); +}); + +it('can mark multiple new lines in the generated code', () => { + builder.markLines(4); + expect(builder.toString()).toEqual(';;;;'); +}); + +it('can mark zero new lines in the generated code', () => { + builder.markLines(0); + expect(builder.toString()).toEqual(''); +}); + +it('does not add commas when just starting a segment', () => { + builder.startSegment(0); + expect(builder.toString()).toEqual('A'); +}); + +it('adds a comma when starting a segment after another segment', () => { + builder.startSegment(0); + builder.startSegment(1); + expect(builder.toString()).toEqual('A,C'); +}); + +it('does not add a comma when starting a segment after marking a line', () => { + builder.startSegment(0); + builder.markLines(1); + builder.startSegment(0); + expect(builder.toString()).toEqual('A;A'); +}); + +it('adds a comma when starting a segment after calling `markLines(0)`', () => { + builder.startSegment(0); + builder.markLines(0); + builder.startSegment(1); + expect(builder.toString()).toEqual('A,C'); +}); + +it('can append values that fit within 5 bits (including sign bit)', () => { + builder.append(0b1111); + builder.append(-0b1111); + expect(builder.toString()).toEqual('ef'); +}); + +it('can append values that fit within 10 bits (including sign bit)', () => { + builder.append(0b111100110); + builder.append(-0b110110011); + expect(builder.toString()).toEqual('senb'); +}); + +it('can append values that fit within 15 bits (including sign bit)', () => { + builder.append(0b10011111011001); + builder.append(-0b11001010001001); + expect(builder.toString()).toEqual('y9TzoZ'); +}); + +it('can append values that fit within 20 bits (including sign bit)', () => { + builder.append(0b1110010011101110110); + builder.append(-0b1011000010100100110); + expect(builder.toString()).toEqual('s3zctyiW'); +}); + +it('can append values that fit within 25 bits (including sign bit)', () => { + builder.append(0b100010001111011010110111); + builder.append(-0b100100111100001110101111); + expect(builder.toString()).toEqual('ur7jR/6hvS'); +}); + +it('can append values that fit within 30 bits (including sign bit)', () => { + builder.append(0b10001100100001101010001011111); + builder.append(-0b11111000011000111110011111101); + expect(builder.toString()).toEqual('+lqjyR7v+xhf'); +}); + +it('can append values that fit within 32 bits (including sign bit)', () => { + builder.append(0b1001100101000101001011111110011); + builder.append(-0b1101101101011000110011001110000); + expect(builder.toString()).toEqual('m/rq0sChnzx1tD'); +}); + +it('can handle multiple operations', () => { + builder + .markLines(3) + .startSegment(4) + .append(2) + .append(2) + .append(0) + .append(2345) + .startSegment(12) + .append(987543) + .markLines(1) + .startSegment(0); + expect(builder.toString()).toEqual(';;;IEEAyyE,Yu5o8B;A'); +}); diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/Generator-test.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/Generator-test.js new file mode 100644 index 00000000..0cad1999 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/Generator-test.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2017-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.disableAutomock(); + +const Generator = require('../Generator'); + +const {objectContaining} = expect; + +let generator; +beforeEach(() => { + generator = new Generator(); +}); + +it('adds file name and source code when starting a file', () => { + const file1 = 'just/a/file'; + const file2 = 'another/file'; + const source1 = 'var a = 1;'; + const source2 = 'var a = 2;'; + + generator.startFile(file1, source1); + generator.startFile(file2, source2); + + expect(generator.toMap()) + .toEqual(objectContaining({ + sources: [file1, file2], + sourcesContent: [source1, source2], + })); +}); + +it('throws when adding a mapping without starting a file', () => { + expect(() => generator.addSimpleMapping(1, 2)).toThrow(); +}); + +it('throws when adding a mapping after ending a file', () => { + generator.startFile('apples', 'pears'); + generator.endFile(); + expect(() => generator.addSimpleMapping(1, 2)).toThrow(); +}); + +it('can add a mapping for generated code without corresponding original source', () => { + generator.startFile('apples', 'pears'); + generator.addSimpleMapping(12, 87); + expect(generator.toMap()) + .toEqual(objectContaining({ + mappings: ';;;;;;;;;;;uF', + })); +}); + +it('can add a mapping with corresponding location in the original source', () => { + generator.startFile('apples', 'pears'); + generator.addSourceMapping(2, 3, 456, 7); + expect(generator.toMap()) + .toEqual(objectContaining({ + mappings: ';GAucO', + })); +}); + +it('can add a mapping with source location and symbol name', () => { + generator.startFile('apples', 'pears'); + generator.addNamedSourceMapping(9, 876, 54, 3, 'arbitrary'); + expect(generator.toMap()) + .toEqual(objectContaining({ + mappings: ';;;;;;;;42BAqDGA', + names: ['arbitrary'], + })); +}); + +describe('full map generation', () => { + beforeEach(() => { + generator.startFile('apples', 'pears'); + generator.addSimpleMapping(1, 2); + generator.addNamedSourceMapping(3, 4, 5, 6, 'plums'); + generator.endFile(); + generator.startFile('lemons', 'oranges'); + generator.addNamedSourceMapping(7, 8, 9, 10, 'tangerines'); + generator.addNamedSourceMapping(11, 12, 13, 14, 'tangerines'); + generator.addSimpleMapping(15, 16); + }); + + it('can add multiple mappings for each file', () => { + expect(generator.toMap()).toEqual({ + version: 3, + mappings: 'E;;IAIMA;;;;QCIIC;;;;YAIIA;;;;gB', + sources: ['apples', 'lemons'], + sourcesContent: ['pears', 'oranges'], + names: ['plums', 'tangerines'], + }); + }); + + it('can add a `file` property to the map', () => { + expect(generator.toMap('arbitrary')) + .toEqual(objectContaining({ + file: 'arbitrary', + })); + }); + + it('supports direct JSON serialization', () => { + expect(JSON.parse(generator.toString())).toEqual(generator.toMap()); + }); + + it('supports direct JSON serialization with a file name', () => { + const file = 'arbitrary/file'; + expect(JSON.parse(generator.toString(file))).toEqual(generator.toMap(file)); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/source-map-test.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/source-map-test.js new file mode 100644 index 00000000..39258eae --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/__tests__/source-map-test.js @@ -0,0 +1,85 @@ + /** + * Copyright (c) 2017-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.disableAutomock(); + +const Generator = require('../Generator'); +const {compactMapping, fromRawMappings} = require('..'); + +describe('flattening mappings / compacting', () => { + it('flattens simple mappings', () => { + expect(compactMapping({generated: {line: 12, column: 34}})) + .toEqual([12, 34]); + }); + + it('flattens mappings with a source location', () => { + expect(compactMapping({ + generated: {column: 34, line: 12}, + original: {column: 78, line: 56}, + })).toEqual([12, 34, 56, 78]); + }); + + it('flattens mappings with a source location and a symbol name', () => { + expect(compactMapping({ + generated: {column: 34, line: 12}, + name: 'arbitrary', + original: {column: 78, line: 56}, + })).toEqual([12, 34, 56, 78, 'arbitrary']); + }); +}); + +describe('build map from raw mappings', () => { + it('returns a `Generator` instance', () => { + expect(fromRawMappings([])).toBeInstanceOf(Generator); + }); + + it('returns a working source map containing all mappings', () => { + const input = [{ + code: lines(11), + map: [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'] + ], + sourceCode: 'code1', + sourcePath: 'path1', + }, { + code: lines(3), + map: [ + [1, 2], + [3, 4, 15, 16, 'bananas'], + ], + sourceCode: 'code2', + sourcePath: 'path2', + }, { + code: lines(23), + map: [ + [11, 12], + [13, 14, 15, 16, 'bananas'], + [17, 18, 19, 110], + [21, 112, 113, 114, 'pears'] + ], + sourceCode: 'code3', + sourcePath: 'path3', + }]; + + expect(fromRawMappings(input).toMap()) + .toEqual({ + mappings: 'E;;IAIMA;;;;QAII;;;;YAIIC;E;;ICEEC;;;;;;;;;;;Y;;cCAAA;;;;kBAI8F;;;;gHA8FID', + names: ['apples', 'pears', 'bananas'], + sources: ['path1', 'path2', 'path3'], + sourcesContent: ['code1', 'code2', 'code3'], + version: 3, + }); + }); +}); + +const lines = n => Array(n).join('\n'); diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/encode.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/encode.js new file mode 100644 index 00000000..f7aa0a96 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/encode.js @@ -0,0 +1,125 @@ +/** + * 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. + * + * @flow + */ +/** + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + * + * Based on the Base 64 VLQ implementation in Closure Compiler: + * https://code.google.com/p/closure-compiler/source/browse/trunk/src/com/google/debugging/sourcemap/Base64VLQ.java + * + * Copyright 2011 The Closure Compiler Authors. All rights reserved. + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @copyright + */ + +/* eslint-disable no-bitwise */ + +'use strict'; + +// A map of values to characters for the b64 encoding +const CHAR_MAP = [ + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, + 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7a, 0x30, 0x31, 0x32, 0x33, + 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x2b, 0x2f, +]; + +// A single base 64 digit can contain 6 bits of data. For the base 64 variable +// length quantities we use in the source map spec, the first bit is the sign, +// the next four bits are the actual value, and the 6th bit is the +// continuation bit. The continuation bit tells us whether there are more +// digits in this value following this digit. +// +// Continuation +// | Sign +// | | +// V V +// 101011 + +const VLQ_BASE_SHIFT = 5; + +// binary: 100000 +const VLQ_BASE = 1 << VLQ_BASE_SHIFT; + +// binary: 011111 +const VLQ_BASE_MASK = VLQ_BASE - 1; + +// binary: 100000 +const VLQ_CONTINUATION_BIT = VLQ_BASE; + +/** + * Converts from a two-complement value to a value where the sign bit is + * placed in the least significant bit. For example, as decimals: + * 1 becomes 2 (10 binary), -1 becomes 3 (11 binary) + * 2 becomes 4 (100 binary), -2 becomes 5 (101 binary) + */ +function toVLQSigned(value) { + return value < 0 + ? ((-value) << 1) + 1 + : (value << 1) + 0; +} + +/** + * Encodes a number to base64 VLQ format and appends it to the passed-in buffer + * + * DON'T USE COMPOUND OPERATORS (eg `>>>=`) ON `let`-DECLARED VARIABLES! + * V8 WILL DEOPTIMIZE THIS FUNCTION AND MAP CREATION WILL BE 25% SLOWER! + * + * DON'T ADD MORE COMMENTS TO THIS FUNCTION TO KEEP ITS LENGTH SHORT ENOUGH FOR + * V8 OPTIMIZATION! + */ +function encode(value: number, buffer: Buffer, position: number): number { + let digit, vlq = toVLQSigned(value); + do { + digit = vlq & VLQ_BASE_MASK; + vlq = vlq >>> VLQ_BASE_SHIFT; + if (vlq > 0) { + // There are still more digits in this value, so we must make sure the + // continuation bit is marked. + digit = digit | VLQ_CONTINUATION_BIT; + } + buffer[position++] = CHAR_MAP[digit]; + } while (vlq > 0); + + return position; +} + +module.exports = encode; diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/package.json b/packages/metro-bundler/react-packager/src/Bundler/source-map/package.json new file mode 100644 index 00000000..be5a9ee4 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/package.json @@ -0,0 +1 @@ +{"main": "source-map.js"} diff --git a/packages/metro-bundler/react-packager/src/Bundler/source-map/source-map.js b/packages/metro-bundler/react-packager/src/Bundler/source-map/source-map.js new file mode 100644 index 00000000..13d8fea4 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Bundler/source-map/source-map.js @@ -0,0 +1,104 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Generator = require('./Generator'); + +import type ModuleTransport from '../../lib/ModuleTransport'; +import type {RawMapping as BabelRawMapping} from 'babel-generator'; + +type GeneratedCodeMapping = [number, number]; +type SourceMapping = [number, number, number, number]; +type SourceMappingWithName = [number, number, number, number, string]; + +export type RawMapping = + SourceMappingWithName | SourceMapping | GeneratedCodeMapping; + +/** + * Creates a source map from modules with "raw mappings", i.e. an array of + * tuples with either 2, 4, or 5 elements: + * generated line, generated column, source line, source line, symbol name. + */ +function fromRawMappings(modules: Array): Generator { + const generator = new Generator(); + let carryOver = 0; + + for (var j = 0, o = modules.length; j < o; ++j) { + var module = modules[j]; + var {code, map} = module; + + if (Array.isArray(map)) { + addMappingsForFile(generator, map, module, carryOver); + } else if (map != null) { + throw new Error( + `Unexpected module with full source map found: ${module.sourcePath}` + ); + } + + carryOver = carryOver + countLines(code); + } + + return generator; +} + +function compactMapping(mapping: BabelRawMapping): RawMapping { + const {column, line} = mapping.generated; + const {name, original} = mapping; + + if (original == null) { + return [line, column]; + } + + if (typeof name !== 'string') { + return [line, column, original.line, original.column]; + } + + return [line, column, original.line, original.column, name]; +} + +function addMappingsForFile(generator, mappings, module, carryOver) { + generator.startFile(module.sourcePath, module.sourceCode); + + const columnOffset = module.code.indexOf('{') + 1; + for (let i = 0, n = mappings.length; i < n; ++i) { + addMapping(generator, mappings[i], carryOver, columnOffset); + } + + generator.endFile(); + +} + +function addMapping(generator, mapping, carryOver, columnOffset) { + const n = mapping.length; + const line = mapping[0] + carryOver; + // lines start at 1, columns start at 0 + const column = mapping[0] === 1 ? mapping[1] + columnOffset : mapping[1]; + if (n === 2) { + generator.addSimpleMapping(line, column); + } else if (n === 4) { + // $FlowIssue #15579526 + generator.addSourceMapping(line, column, mapping[2], mapping[3]); + } else if (n === 5) { + generator.addNamedSourceMapping( + // $FlowIssue #15579526 + line, column, mapping[2], mapping[3], mapping[4]); + } else { + throw new Error(`Invalid mapping: [${mapping.join(', ')}]`); + } +} + +function countLines(string) { + return string.split('\n').length; +} + +exports.fromRawMappings = fromRawMappings; +exports.compactMapping = compactMapping; diff --git a/packages/metro-bundler/react-packager/src/Cache/__mocks__/Cache.js b/packages/metro-bundler/react-packager/src/Cache/__mocks__/Cache.js new file mode 100644 index 00000000..1376f275 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Cache/__mocks__/Cache.js @@ -0,0 +1,33 @@ +/** + * 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'; + +const mockColor = () => { + return { + bold: () => { return { }; }, + }; +}; + +mockColor.bold = function() { + return {}; +}; + +module.exports = { + dim: s => s, + magenta: mockColor, + white: mockColor, + blue: mockColor, + yellow: mockColor, + green: mockColor, + bold: mockColor, + red: mockColor, + cyan: mockColor, + gray: mockColor, + black: mockColor, +}; diff --git a/packages/metro-bundler/index.js b/packages/metro-bundler/react-packager/src/JSTransformer/README.md similarity index 100% rename from packages/metro-bundler/index.js rename to packages/metro-bundler/react-packager/src/JSTransformer/README.md diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/lodash.js b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/lodash.js new file mode 100644 index 00000000..ac8224e6 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/lodash.js @@ -0,0 +1,13 @@ +/** + * 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'; + +// Bug with Jest because we're going to the node_modules that is a sibling +// of what jest thinks our root (the dir with the package.json) should be. +module.exports = require.requireActual('lodash'); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/q.js b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/q.js new file mode 100644 index 00000000..7ee0beac --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/q.js @@ -0,0 +1,14 @@ +/** + * 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'; + +// Bug with Jest because we're going to the node_modules that is a sibling +// of what jest thinks our root (the dir with the package.json) should be. + +module.exports = require.requireActual('q'); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/worker.js b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/worker.js new file mode 100644 index 00000000..e36b27e4 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/__mocks__/worker.js @@ -0,0 +1,13 @@ +/** + * 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'; + +module.exports = function (data, callback) { + callback(null, {}); +}; diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/__tests__/Transformer-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/__tests__/Transformer-test.js new file mode 100644 index 00000000..0df1a5f0 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/__tests__/Transformer-test.js @@ -0,0 +1,90 @@ +/** + * 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 + .unmock('imurmurhash') + .unmock('../../lib/ModuleTransport') + .unmock('../'); + +const fs = {writeFileSync: jest.fn()}; +const temp = {path: () => '/arbitrary/path'}; +const workerFarm = jest.fn(); +jest.setMock('fs', fs); +jest.setMock('temp', temp); +jest.setMock('worker-farm', workerFarm); + +var Transformer = require('../'); + +const {any} = jasmine; + +describe('Transformer', function() { + let options, workers, Cache; + const fileName = '/an/arbitrary/file.js'; + const transformModulePath = __filename; + + beforeEach(function() { + Cache = jest.fn(); + Cache.prototype.get = jest.fn((a, b, c) => c()); + + fs.writeFileSync.mockClear(); + options = {transformModulePath}; + workerFarm.mockClear(); + workerFarm.mockImplementation((opts, path, methods) => { + const api = workers = {}; + methods.forEach(method => {api[method] = jest.fn();}); + return api; + }); + }); + + it('passes transform module path, file path, source code,' + + ' and options to the worker farm when transforming', () => { + const transformOptions = {arbitrary: 'options'}; + const code = 'arbitrary(code)'; + new Transformer(options).transformFile(fileName, code, transformOptions); + expect(workers.transformAndExtractDependencies).toBeCalledWith( + transformModulePath, + fileName, + code, + transformOptions, + any(Function), + ); + }); + + it('should add file info to parse errors', function() { + const transformer = new Transformer(options); + var message = 'message'; + var snippet = 'snippet'; + + workers.transformAndExtractDependencies.mockImplementation( + function(transformPath, filename, code, opts, callback) { + var babelError = new SyntaxError(message); + babelError.type = 'SyntaxError'; + babelError.description = message; + babelError.loc = { + line: 2, + column: 15, + }; + babelError.codeFrame = snippet; + callback(babelError); + }, + ); + + return transformer.transformFile(fileName, '', {}) + .catch(function(error) { + expect(error.type).toEqual('TransformError'); + expect(error.message).toBe('SyntaxError ' + message); + expect(error.lineNumber).toBe(2); + expect(error.column).toBe(15); + expect(error.filename).toBe(fileName); + expect(error.description).toBe(message); + expect(error.snippet).toBe(snippet); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/index.js b/packages/metro-bundler/react-packager/src/JSTransformer/index.js new file mode 100644 index 00000000..9ebcf053 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/index.js @@ -0,0 +1,228 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Logger = require('../Logger'); + +const declareOpts = require('../lib/declareOpts'); +const denodeify = require('denodeify'); +const os = require('os'); +const util = require('util'); +const workerFarm = require('worker-farm'); +const debug = require('debug')('RNP:JStransformer'); + +import type {Data as TransformData, Options as TransformOptions} from './worker/worker'; +import type {SourceMap} from '../lib/SourceMap'; + +// Avoid memory leaks caused in workers. This number seems to be a good enough number +// to avoid any memory leak while not slowing down initial builds. +// TODO(amasad): Once we get bundle splitting, we can drive this down a bit more. +const MAX_CALLS_PER_WORKER = 600; + +// Worker will timeout if one of the callers timeout. +const DEFAULT_MAX_CALL_TIME = 301000; + +// How may times can we tolerate failures from the worker. +const MAX_RETRIES = 2; + +const validateOpts = declareOpts({ + transformModulePath: { + type:'string', + required: false, + }, + transformTimeoutInterval: { + type: 'number', + default: DEFAULT_MAX_CALL_TIME, + }, + worker: { + type: 'string', + }, + methods: { + type: 'array', + default: [], + }, +}); + +type Options = { + transformModulePath?: ?string, + transformTimeoutInterval?: ?number, + worker?: ?string, + methods?: ?Array, +}; + +const maxConcurrentWorkers = ((cores, override) => { + if (override) { + return Math.min(cores, override); + } + + if (cores < 3) { + return cores; + } + if (cores < 8) { + return Math.floor(cores * 0.75); + } + if (cores < 24) { + return Math.floor(3 / 8 * cores + 3); // between cores *.75 and cores / 2 + } + return cores / 2; +})(os.cpus().length, parseInt(process.env.REACT_NATIVE_MAX_WORKERS, 10)); + +function makeFarm(worker, methods, timeout) { + return workerFarm( + { + autoStart: true, + maxConcurrentCallsPerWorker: 1, + maxConcurrentWorkers: maxConcurrentWorkers, + maxCallsPerWorker: MAX_CALLS_PER_WORKER, + maxCallTime: timeout, + maxRetries: MAX_RETRIES, + }, + worker, + methods, + ); +} + +class Transformer { + + _opts: { + transformModulePath?: ?string, + transformTimeoutInterval: number, + worker: ?string, + methods: Array, + }; + _workers: {[name: string]: mixed}; + _transformModulePath: ?string; + _transform: ( + transform: string, + filename: string, + sourceCode: string, + options: ?TransformOptions, + ) => Promise; + minify: ( + filename: string, + code: string, + sourceMap: SourceMap, + ) => Promise<{code: string, map: SourceMap}>; + + constructor(options: Options) { + const opts = this._opts = validateOpts(options); + + const {transformModulePath} = opts; + + if (opts.worker) { + this._workers = + makeFarm(opts.worker, opts.methods, opts.transformTimeoutInterval); + opts.methods.forEach(name => { + /* $FlowFixMe: assigning the class object fields directly is + * questionable, because it's prone to conflicts. */ + this[name] = this._workers[name]; + }); + } + else if (transformModulePath) { + this._transformModulePath = require.resolve(transformModulePath); + + this._workers = makeFarm( + require.resolve('./worker'), + ['minify', 'transformAndExtractDependencies'], + opts.transformTimeoutInterval, + ); + this._transform = denodeify(this._workers.transformAndExtractDependencies); + this.minify = denodeify(this._workers.minify); + } + } + + kill() { + this._workers && workerFarm.end(this._workers); + } + + transformFile(fileName: string, code: string, options: TransformOptions) { + if (!this._transform) { + return Promise.reject(new Error('No transform module')); + } + debug('transforming file', fileName); + return this + /* $FlowFixMe: _transformModulePath may be empty, see constructor */ + ._transform(this._transformModulePath, fileName, code, options) + .then(data => { + Logger.log(data.transformFileStartLogEntry); + Logger.log(data.transformFileEndLogEntry); + debug('done transforming file', fileName); + return data.result; + }) + .catch(error => { + if (error.type === 'TimeoutError') { + const timeoutErr = new Error( + `TimeoutError: transforming ${fileName} took longer than ` + + `${this._opts.transformTimeoutInterval / 1000} seconds.\n` + + 'You can adjust timeout via the \'transformTimeoutInterval\' option' + ); + /* $FlowFixMe: monkey-patch Error */ + timeoutErr.type = 'TimeoutError'; + throw timeoutErr; + } else if (error.type === 'ProcessTerminatedError') { + const uncaughtError = new Error( + 'Uncaught error in the transformer worker: ' + + /* $FlowFixMe: _transformModulePath may be empty, see constructor */ + this._opts.transformModulePath + ); + /* $FlowFixMe: monkey-patch Error */ + uncaughtError.type = 'ProcessTerminatedError'; + throw uncaughtError; + } + + throw formatError(error, fileName); + }); + } + + static TransformError; +} + +module.exports = Transformer; + +Transformer.TransformError = TransformError; + +function TransformError() { + Error.captureStackTrace && Error.captureStackTrace(this, TransformError); +} +util.inherits(TransformError, SyntaxError); + +function formatError(err, filename, source) { + if (err.loc) { + return formatBabelError(err, filename, source); + } else { + return formatGenericError(err, filename, source); + } +} + +function formatGenericError(err, filename) { + var msg = 'TransformError: ' + filename + ': ' + err.message; + var error = new TransformError(); + var stack = (err.stack || '').split('\n').slice(0, -1); + stack.push(msg); + error.stack = stack.join('\n'); + error.message = msg; + error.type = 'TransformError'; + return error; +} + +function formatBabelError(err, filename) { + var error = new TransformError(); + error.type = 'TransformError'; + error.message = (err.type || error.type) + ' ' + err.message; + error.stack = err.stack; + error.snippet = err.codeFrame; + error.lineNumber = err.loc.line; + error.column = err.loc.column; + error.filename = filename; + error.description = err.message; + return error; +} diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js new file mode 100644 index 00000000..b1899fca --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/constant-folding-test.js @@ -0,0 +1,122 @@ +/** + * 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.disableAutomock(); +const babel = require('babel-core'); +const constantFolding = require('../constant-folding'); + +function parse(code) { + return babel.transform(code, {code: false, babelrc: false, compact: true}); +} + +const babelOptions = { + babelrc: false, + compact: true, + retainLines: false, +}; + +function normalize({code}) { + return babel.transform(code, babelOptions).code; +} + +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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(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(normalize(constantFolding('arbitrary.js', parse(code)))) + .toEqual('{{require(\'c\');}}'); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-test.js new file mode 100644 index 00000000..998ee7da --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/extract-dependencies-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.disableAutomock(); + +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, 98]); + }); + + 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 extract calls to require with non-static arguments', () => { + const code = 'require(\'foo/\' + bar)'; + + 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]); + }); + + it('can handle regular expressions', () => { + const code = 'require(\'a\'); /["\']/.test(\'foo\'); require("b");'; + + const {dependencies, dependencyOffsets} = extractDependencies(code); + expect(dependencies).toEqual(['a', 'b']); + expect(dependencyOffsets).toEqual([8, 42]); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/inline-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/inline-test.js new file mode 100644 index 00000000..c97b7a97 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/inline-test.js @@ -0,0 +1,306 @@ +/** + * 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.disableAutomock(); +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 Platform.OS in the code if Platform is a top level import from react-native', () => { + const code = ` + var Platform = require('react-native').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 ReactNative.Platform.OS in the code if ReactNative is a global', () => { + const code = `function a() { + var a = ReactNative.Platform.OS; + var b = a.ReactNative.Platform.OS; + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/ReactNative\.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 ReactNative.Platform.OS in the code if ReactNative is a top level import', () => { + const code = ` + var ReactNative = require('react-native'); + function a() { + if (ReactNative.Platform.OS === 'android') a = function() {}; + var b = a.ReactNative.Platform.OS; + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual(normalize(code.replace(/ReactNative.Platform\.OS/, '"android"'))); + }); + + it('replaces require("react-native").Platform.OS in the code', () => { + const code = `function a() { + var a = require('react-native').Platform.OS; + var b = a.require('react-native').Platform.OS; + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\('react-native'\)\.Platform\.OS/, '"android"'))); + }); + + it('inlines Platform.select in the code if Platform is a global and the argument is an object literal', () => { + const code = `function a() { + var a = Platform.select({ios: 1, android: 2}); + var b = a.Platform.select({ios: 1, android: 2}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.select[^;]+/, '1'))); + }); + + it('replaces Platform.select in the code if Platform is a top level import', () => { + const code = ` + var Platform = require('Platform'); + function a() { + Platform.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.select[^;]+/, '2'))); + }); + + it('replaces Platform.select in the code if Platform is a top level import from react-native', () => { + const code = ` + var Platform = require('react-native').Platform; + function a() { + Platform.select({ios: 1, android: 2}); + var b = a.Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.select[^;]+/, '1'))); + }); + + it('replaces require("Platform").select in the code', () => { + const code = `function a() { + var a = require('Platform').select({ios: 1, android: 2}); + var b = a.require('Platform').select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual(normalize(code.replace(/Platform\.select[^;]+/, '2'))); + }); + + it('replaces React.Platform.select in the code if React is a global', () => { + const code = `function a() { + var a = React.Platform.select({ios: 1, android: 2}); + var b = a.React.Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/React\.Platform\.select[^;]+/, '1'))); + }); + + it('replaces ReactNative.Platform.select in the code if ReactNative is a global', () => { + const code = `function a() { + var a = ReactNative.Platform.select({ios: 1, android: 2}); + var b = a.ReactNative.Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/ReactNative\.Platform\.select[^;]+/, '1'))); + }); + + it('replaces React.Platform.select in the code if React is a top level import', () => { + const code = ` + var React = require('React'); + function a() { + var a = React.Platform.select({ios: 1, android: 2}); + var b = a.React.Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'ios'}); + expect(toString(ast)).toEqual(normalize(code.replace(/React\.Platform\.select[^;]+/, '1'))); + }); + + it('replaces require("React").Platform.select in the code', () => { + const code = `function a() { + var a = require('React').Platform.select({ios: 1, android: 2}); + var b = a.require('React').Platform.select({}); + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\('React'\)\.Platform\.select[^;]+/, '2'))); + }); + + it('replaces ReactNative.Platform.select in the code if ReactNative is a top level import', () => { + const code = ` + var ReactNative = require('react-native'); + function a() { + var a = ReactNative.Plaftform.select({ios: 1, android: 2}); + var b = a.ReactNative.Platform.select; + }`; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual(normalize(code.replace(/ReactNative.Platform\.select[^;]+/, '2'))); + }); + + it('replaces require("react-native").Platform.select in the code', () => { + const code = ` + var a = require('react-native').Platform.select({ios: 1, android: 2}); + var b = a.require('react-native').Platform.select({}); + `; + const {ast} = inline('arbitrary.js', {code}, {platform: 'android'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\('react-native'\)\.Platform\.select[^;]+/, '2'))); + }); + + it('replaces non-existing properties with `undefined`', () => { + const code = 'var a = Platform.select({ios: 1, android: 2})'; + const {ast} = inline('arbitrary.js', {code}, {platform: 'doesnotexist'}); + expect(toString(ast)).toEqual( + normalize(code.replace(/Platform\.select[^;]+/, 'undefined'))); + }); + + 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')); + }); + + it('can work with wrapped modules', () => { + const code = `__arbitrary(function() { + var Platform = require('react-native').Platform; + var a = Platform.OS, b = Platform.select({android: 1, ios: 2}); + });`; + const {ast} = inline( + 'arbitrary', {code}, {dev: true, platform: 'android', isWrapped: true}); + expect(toString(ast)).toEqual( + normalize( + code + .replace(/Platform\.OS/, '"android"') + .replace(/Platform\.select[^)]+\)/, 1) + ) + ); + }); + + it('can work with transformed require calls', () => { + const code = `__arbitrary(require, function(arbitraryMapName) { + var a = require(arbitraryMapName[123], 'react-native').Platform.OS; + });`; + const {ast} = inline( + 'arbitrary', {code}, {dev: true, platform: 'android', isWrapped: true}); + expect(toString(ast)).toEqual( + normalize(code.replace(/require\([^)]+\)\.Platform\.OS/, '"android"'))); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/minify-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/minify-test.js new file mode 100644 index 00000000..8eff672e --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/minify-test.js @@ -0,0 +1,57 @@ +/** + * 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.disableAutomock(); + +const uglify = { + minify: jest.fn(code => { + return { + code: code.replace(/(^|\W)\s+/g, '$1'), + map: {}, + }; + }), +}; +jest.setMock('uglify-js', uglify); + +const minify = require('../minify'); +const {objectContaining} = jasmine; + +describe('Minification:', () => { + const filename = '/arbitrary/file.js'; + const code = 'arbitrary(code)'; + let map; + + beforeEach(() => { + uglify.minify.mockClear(); + uglify.minify.mockReturnValue({code: '', map: '{}'}); + map = {version: 3, sources: ['?'], mappings: ''}; + }); + + it('passes file name, code, and source map to `uglify`', () => { + minify(filename, code, map); + expect(uglify.minify).toBeCalledWith(code, objectContaining({ + fromString: true, + inSourceMap: map, + outSourceMap: true, + })); + }); + + it('returns the code provided by uglify', () => { + uglify.minify.mockReturnValue({code, map: '{}'}); + const result = minify('', '', {}); + expect(result.code).toBe(code); + }); + + it('parses the source map object provided by uglify and sets the sources property', () => { + uglify.minify.mockReturnValue({map: JSON.stringify(map), code: ''}); + const result = minify(filename, '', {}); + expect(result.map).toEqual({...map, sources: [filename]}); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/worker-test.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/worker-test.js new file mode 100644 index 00000000..4421b3a1 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/__tests__/worker-test.js @@ -0,0 +1,234 @@ +/** + * 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.disableAutomock(); +jest.mock('../constant-folding'); +jest.mock('../extract-dependencies'); +jest.mock('../inline'); +jest.mock('../minify'); + +const {any, objectContaining} = jasmine; + +describe('code transformation worker:', () => { + let transformCode; + + let extractDependencies, transform; + beforeEach(() => { + jest.resetModules(); + ({transformCode} = require('..')); + extractDependencies = + require('../extract-dependencies').mockReturnValue({}); + transform = jest.fn(); + }); + + 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 in the cache', done => { + const result = { + code: 'some.other(code)', + map: {} + }; + transform.mockImplementation((_, callback) => + callback(null, result)); + + transformCode(transform, 'filename', 'code', {}, (error, data) => { + expect(error).toBeNull(); + expect(data.result).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)); + const filePath = 'arbitrary/file.json'; + transformCode(transform, filePath, 'b', {}, (error, data) => { + expect(error).toBeNull(); + expect(data.result.code).toEqual('{a:1,b:2}'); + done(); + }, + ); + } + ); + + it('removes shebang when present', done => { + const shebang = '#!/usr/bin/env node'; + const result = { + code: `${shebang} \n arbitrary(code)`, + }; + transform.mockImplementation((_, callback) => callback(null, result)); + const filePath = 'arbitrary/file.js'; + transformCode(transform, filePath, 'b', {}, (error, data) => { + expect(error).toBeNull(); + const {code} = data.result; + expect(code).not.toContain(shebang); + expect(code.split('\n').length).toEqual(result.code.split('\n').length); + 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(); + }); + }); + + 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', {}, (error) => { + expect(error).toBeNull(); + 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', {}, (error, data) => { + expect(error).toBeNull(); + expect(data.result).toEqual(objectContaining(dependencyData)); + done(); + }); + } + ); + + it('does not extract requires if files are marked as "extern"', done => { + const opts = {extern: true}; + transformCode(transform, 'filename', 'code', opts, (error, data) => { + expect(error).toBeNull(); + const {dependencies, dependencyOffsets} = data.result; + expect(extractDependencies).not.toBeCalled(); + expect(dependencies).toEqual([]); + expect(dependencyOffsets).toEqual([]); + done(); + }); + }); + + it('does not extract requires of JSON files', done => { + const jsonStr = '{"arbitrary":"json"}'; + transformCode(transform, 'arbitrary.json', jsonStr, {}, (error, data) => { + expect(error).toBeNull(); + const {dependencies, dependencyOffsets} = data.result; + expect(extractDependencies).not.toBeCalled(); + expect(dependencies).toEqual([]); + expect(dependencyOffsets).toEqual([]); + done(); + } + ); + }); + }); + + describe('Minifications:', () => { + let constantFolding, inline, options; + let transformResult, dependencyData; + 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'); + + options = {minify: true, transform: {generateSourceMaps: 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('uses the dependencies obtained from the optimized result', done => { + transformCode(transform, filename, 'code', options, (_, data) => { + const result = data.result; + expect(result.dependencies).toEqual(dependencyData.dependencies); + done(); + }); + }); + + it('uses data produced by `constant-folding` for the result', done => { + transformCode(transform, 'filename', 'code', options, (_, data) => { + expect(data.result) + .toEqual(objectContaining({code: foldedCode, map: foldedMap})); + done(); + }); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/constant-folding.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/constant-folding.js new file mode 100644 index 00000000..e404b61c --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/constant-folding.js @@ -0,0 +1,91 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const babel = require('babel-core'); + +import type {Ast, SourceMap} from 'babel-core'; +const t = babel.types; + +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: string, transformResult: { + ast: Ast, + code?: ?string, + map: ?SourceMap, +}) { + return babel.transformFromAst(transformResult.ast, transformResult.code, { + filename, + plugins: [plugin], + inputSourceMap: transformResult.map, + sourceMaps: true, + sourceFileName: filename, + babelrc: false, + compact: true, + retainLines: true, + }); +} + +constantFolding.plugin = plugin; +module.exports = constantFolding; diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/extract-dependencies.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/extract-dependencies.js new file mode 100644 index 00000000..934555fe --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/extract-dependencies.js @@ -0,0 +1,47 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const babel = require('babel-core'); +const babylon = require('babylon'); + +/** + * Extracts dependencies (module IDs imported with the `require` function) from + * a string containing code. This walks the full AST for correctness (versus + * using, for example, regular expressions, that would be faster but inexact.) + * + * 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: string) { + const ast = babylon.parse(code); + const dependencies = new Set(); + const dependencyOffsets = []; + + babel.traverse(ast, { + CallExpression(path) { + const node = path.node; + const callee = node.callee; + const arg = node.arguments[0]; + if (callee.type !== 'Identifier' || callee.name !== 'require' || !arg || arg.type !== 'StringLiteral') { + return; + } + dependencyOffsets.push(arg.start); + dependencies.add(arg.value); + } + }); + + return {dependencyOffsets, dependencies: Array.from(dependencies)}; +} + +module.exports = extractDependencies; diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/index.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/index.js new file mode 100644 index 00000000..c1a634ef --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/index.js @@ -0,0 +1,13 @@ +/** + * 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'; + +require('../../../../../setupBabel')(); +module.exports = require('./worker'); diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/inline.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/inline.js new file mode 100644 index 00000000..c9bdee03 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/inline.js @@ -0,0 +1,188 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const babel = require('babel-core'); +const invariant = require('fbjs/lib/invariant'); + +import type {Ast, SourceMap} from 'babel-core'; +const t = babel.types; + +const React = {name: 'React'}; +const ReactNative = {name: 'ReactNative'}; +const platform = {name: 'Platform'}; +const os = {name: 'OS'}; +const select = {name: 'select'}; +const requirePattern = {name: 'require'}; + +const env = {name: 'env'}; +const nodeEnv = {name: 'NODE_ENV'}; +const processId = {name: 'process'}; + +const dev = {name: '__DEV__'}; + +const importMap = new Map([['ReactNative', 'react-native']]); + +const isGlobal = (binding) => !binding; + +const isToplevelBinding = (binding, isWrappedModule) => + isGlobal(binding) || + !binding.scope.parent || + isWrappedModule && !binding.scope.parent.parent; + +const isRequireCall = (node, dependencyId, scope) => + t.isCallExpression(node) && + t.isIdentifier(node.callee, requirePattern) && + checkRequireArgs(node.arguments, dependencyId); + +const isImport = (node, scope, patterns) => + patterns.some(pattern => { + const importName = importMap.get(pattern.name) || pattern.name; + return isRequireCall(node, importName, scope); + }); + +function isImportOrGlobal(node, scope, patterns, isWrappedModule) { + const identifier = patterns.find(pattern => t.isIdentifier(node, pattern)); + return ( + identifier && + isToplevelBinding(scope.getBinding(identifier.name), isWrappedModule) || + isImport(node, scope, patterns) + ); +} + +const isPlatformOS = (node, scope, isWrappedModule) => + t.isIdentifier(node.property, os) && + isImportOrGlobal(node.object, scope, [platform], isWrappedModule); + +const isReactPlatformOS = (node, scope, isWrappedModule) => + t.isIdentifier(node.property, os) && + t.isMemberExpression(node.object) && + t.isIdentifier(node.object.property, platform) && + isImportOrGlobal( + node.object.object, scope, [React, ReactNative], isWrappedModule); + +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 isPlatformSelect = (node, scope, isWrappedModule) => + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.object, platform) && + t.isIdentifier(node.callee.property, select) && + isImportOrGlobal(node.callee.object, scope, [platform], isWrappedModule); + +const isReactPlatformSelect = (node, scope, isWrappedModule) => + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, select) && + t.isMemberExpression(node.callee.object) && + t.isIdentifier(node.callee.object.property, platform) && + isImportOrGlobal( + node.callee.object.object, scope, [React, ReactNative], isWrappedModule); + +const isDev = (node, parent, scope) => + t.isIdentifier(node, dev) && + isGlobal(scope.getBinding(dev.name)) && + !(t.isMemberExpression(parent)); + +function findProperty(objectExpression, key) { + const property = objectExpression.properties.find(p => p.key.name === key); + return property ? property.value : t.identifier('undefined'); +} + +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; + const opts = state.opts; + + if ( + isPlatformOS(node, scope, opts.isWrapped) || + isReactPlatformOS(node, scope, opts.isWrapped) + ) { + path.replaceWith(t.stringLiteral(opts.platform)); + } else if (isProcessEnvNodeEnv(node, scope)) { + path.replaceWith( + t.stringLiteral(opts.dev ? 'development' : 'production')); + } + }, + CallExpression(path, state) { + const node = path.node; + const scope = path.scope; + const arg = node.arguments[0]; + const opts = state.opts; + + if ( + isPlatformSelect(node, scope, opts.isWrapped) || + isReactPlatformSelect(node, scope, opts.isWrapped) + ) { + const replacement = t.isObjectExpression(arg) + ? findProperty(arg, opts.platform) + : node; + + path.replaceWith(replacement); + } + } + }, +}; + +const plugin = () => inlinePlugin; + +function checkRequireArgs(args, dependencyId) { + const pattern = t.stringLiteral(dependencyId); + return t.isStringLiteral(args[0], pattern) || + t.isMemberExpression(args[0]) && + t.isNumericLiteral(args[0].property) && + t.isStringLiteral(args[1], pattern); +} + +type AstResult = { + ast: Ast, + code: ?string, + map: ?SourceMap, +}; + +function inline( + filename: string, + transformResult: {ast?: ?Ast, code: string, map: ?SourceMap}, + options: {}, +): AstResult { + const code = transformResult.code; + const babelOptions = { + filename, + plugins: [[plugin, options]], + inputSourceMap: transformResult.map, + sourceMaps: true, + sourceFileName: filename, + code: false, + babelrc: false, + compact: true, + }; + + const result = transformResult.ast + ? babel.transformFromAst(transformResult.ast, code, babelOptions) + : babel.transform(code, babelOptions); + const {ast} = result; + invariant(ast != null, 'Missing AST in babel transform results.'); + return {ast, code: result.code, map: result.map}; +} + +inline.plugin = inlinePlugin; +module.exports = inline; diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/minify.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/minify.js new file mode 100644 index 00000000..e92513a6 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/minify.js @@ -0,0 +1,32 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const uglify = require('uglify-js'); + +function minify(filename: string, code: string, sourceMap: ?string) { + const minifyResult = uglify.minify(code, { + fromString: true, + inSourceMap: sourceMap, + outSourceMap: true, + output: { + ascii_only: true, + screw_ie8: true, + }, + }); + + minifyResult.map = JSON.parse(minifyResult.map); + minifyResult.map.sources = [filename]; + return minifyResult; +} + +module.exports = minify; diff --git a/packages/metro-bundler/react-packager/src/JSTransformer/worker/worker.js b/packages/metro-bundler/react-packager/src/JSTransformer/worker/worker.js new file mode 100644 index 00000000..d97714dc --- /dev/null +++ b/packages/metro-bundler/react-packager/src/JSTransformer/worker/worker.js @@ -0,0 +1,181 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const constantFolding = require('./constant-folding'); +const extractDependencies = require('./extract-dependencies'); +const inline = require('./inline'); +const invariant = require('fbjs/lib/invariant'); +const minify = require('./minify'); + +import type {LogEntry} from '../../Logger/Types'; +import type {Ast, SourceMap, TransformOptions as BabelTransformOptions} from 'babel-core'; + +function makeTransformParams(filename, sourceCode, options, willMinify) { + invariant( + !willMinify || options.generateSourceMaps, + 'Minifying source code requires the `generateSourceMaps` option to be `true`', + ); + + + if (filename.endsWith('.json')) { + sourceCode = 'module.exports=' + sourceCode; + } + + return {filename, sourceCode, options}; +} + +export type TransformedCode = { + code: string, + dependencies: Array, + dependencyOffsets: Array, + map?: ?SourceMap, +}; + +type Transform = ( + params: { + filename: string, + sourceCode: string, + options: ?{}, + }, + callback: ( + error?: Error, + tranformed?: {ast: ?Ast, code: string, map: ?SourceMap}, + ) => mixed, +) => void; + +export type TransformOptions = { + generateSourceMaps: boolean, + platform: string, + preloadedModules?: Array, + projectRoots: Array, + ramGroups?: Array, +} & BabelTransformOptions; + +export type Options = { + +dev: boolean, + +minify: boolean, + platform: string, + transform: TransformOptions, +}; + +export type Data = { + result: TransformedCode, + transformFileStartLogEntry: LogEntry, + transformFileEndLogEntry: LogEntry, +}; + +type Callback = ( + error: ?Error, + data: ?Data, +) => mixed; + +function transformCode( + transform: Transform, + filename: string, + sourceCode: string, + options: Options, + callback: Callback, +) { + const params = makeTransformParams( + filename, + sourceCode, + options.transform, + options.minify, + ); + const isJson = filename.endsWith('.json'); + + const transformFileStartLogEntry = { + action_name: 'Transforming file', + action_phase: 'start', + file_name: filename, + log_entry_label: 'Transforming file', + start_timestamp: process.hrtime(), + }; + + transform(params, (error, transformed) => { + if (error) { + callback(error); + return; + } + + invariant( + transformed != null, + 'Missing transform results despite having no error.', + ); + + var code, map; + if (options.minify) { + ({code, map} = + constantFolding(filename, inline(filename, transformed, options))); + invariant(code != null, 'Missing code from constant-folding transform.'); + } else { + ({code, map} = transformed); + } + + if (isJson) { + code = code.replace(/^\w+\.exports=/, ''); + } else { + // Remove shebang + code = code.replace(/^#!.*/, ''); + } + + const depsResult = isJson || options.extern + ? {dependencies: [], dependencyOffsets: []} + : extractDependencies(code); + + const timeDelta = process.hrtime(transformFileStartLogEntry.start_timestamp); + const duration_ms = Math.round((timeDelta[0] * 1e9 + timeDelta[1]) / 1e6); + const transformFileEndLogEntry = { + action_name: 'Transforming file', + action_phase: 'end', + file_name: filename, + duration_ms: duration_ms, + log_entry_label: 'Transforming file', + }; + + return callback(null, { + result: {...depsResult, code, map}, + transformFileStartLogEntry, + transformFileEndLogEntry, + }); + }); +} + +exports.transformAndExtractDependencies = ( + transform: string, + filename: string, + sourceCode: string, + options: Options, + callback: Callback, +) => { + /* $FlowFixMe: impossible to type a dynamic require */ + const transformModule = require(transform); + transformCode(transformModule, filename, sourceCode, options || {}, callback); +}; + +exports.minify = ( + filename: string, + code: string, + sourceMap: string, + callback: (error: ?Error, result: mixed) => mixed, +) => { + var result; + try { + result = minify(filename, code, sourceMap); + } catch (error) { + callback(error); + } + callback(null, result); +}; + +exports.transformCode = transformCode; // for easier testing diff --git a/packages/metro-bundler/react-packager/src/Logger/Types.js b/packages/metro-bundler/react-packager/src/Logger/Types.js new file mode 100644 index 00000000..b2dfab4b --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Logger/Types.js @@ -0,0 +1,33 @@ +/** + * 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. + * + * @flow + * + */ +'use strict'; + +export type ActionLogEntryData = { + action_name: string, +}; + +export type ActionStartLogEntry = { + action_name?: string, + action_phase?: string, + log_entry_label: string, + log_session?: string, + start_timestamp?: [number, number], +}; + +export type LogEntry = { + action_name?: string, + action_phase?: string, + duration_ms?: number, + log_entry_label: string, + log_session?: string, + start_timestamp?: [number, number], +}; diff --git a/packages/metro-bundler/react-packager/src/Logger/__mocks__/chalk.js b/packages/metro-bundler/react-packager/src/Logger/__mocks__/chalk.js new file mode 100644 index 00000000..bff20b0a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Logger/__mocks__/chalk.js @@ -0,0 +1,37 @@ +/** + * 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'; + +const mockColor = () => { + return { + bold: () => { return { }; }, + }; +}; + +mockColor.bold = function() { + return {}; +}; + +mockColor.bgRed = function() { + return {}; +}; + +module.exports = { + dim: s => s, + magenta: mockColor, + white: mockColor, + blue: mockColor, + yellow: mockColor, + green: mockColor, + bold: mockColor, + red: mockColor, + cyan: mockColor, + gray: mockColor, + black: mockColor, +}; diff --git a/packages/metro-bundler/react-packager/src/Logger/__tests__/Logger-test.js b/packages/metro-bundler/react-packager/src/Logger/__tests__/Logger-test.js new file mode 100644 index 00000000..96d10c3c --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Logger/__tests__/Logger-test.js @@ -0,0 +1,66 @@ +/** + * 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. + * + * eslint-disable no-console-disallow + * + */ +'use strict'; + +jest.disableAutomock(); + +const { + createEntry, + createActionStartEntry, + createActionEndEntry, +} = require('../'); + +describe('Logger', () => { + const originalConsoleLog = console.log; + + beforeEach(() => { + console.log = jest.fn(); + }); + + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('creates simple log entries', () => { + const logEntry = createEntry('Test'); + expect(logEntry).toEqual({ + log_entry_label: 'Test', + log_session: jasmine.any(String), + packager_version: jasmine.any(String), + }); + }); + + it('creates action start log entries', () => { + const actionStartLogEntry = createActionStartEntry('Test'); + expect(actionStartLogEntry).toEqual({ + action_name: 'Test', + action_phase: 'start', + log_entry_label: 'Test', + log_session: jasmine.any(String), + packager_version: jasmine.any(String), + start_timestamp: jasmine.any(Object), + }); + }); + + it('creates action end log entries', () => { + const actionEndLogEntry = createActionEndEntry(createActionStartEntry('Test')); + expect(actionEndLogEntry).toEqual({ + action_name: 'Test', + action_phase: 'end', + duration_ms: jasmine.any(Number), + log_entry_label: 'Test', + log_session: jasmine.any(String), + packager_version: jasmine.any(String), + start_timestamp: jasmine.any(Object), + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/Logger/index.js b/packages/metro-bundler/react-packager/src/Logger/index.js new file mode 100644 index 00000000..cb7ea774 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Logger/index.js @@ -0,0 +1,89 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const os = require('os'); +const pkgjson = require('../../../package.json'); + +const {EventEmitter} = require('events'); + +import type { + ActionLogEntryData, + ActionStartLogEntry, + LogEntry, +} from './Types'; + +const log_session = `${os.hostname()}-${Date.now()}`; +const eventEmitter = new EventEmitter(); + +function on(event: string, handler: (logEntry: LogEntry) => void): void { + eventEmitter.on(event, handler); +} + +function createEntry(data: LogEntry | string): LogEntry { + const logEntry = typeof data === 'string' ? {log_entry_label: data} : data; + + return { + ...logEntry, + log_session, + packager_version: pkgjson.version, + }; +} + +function createActionStartEntry(data: ActionLogEntryData | string): LogEntry { + const logEntry = typeof data === 'string' ? {action_name: data} : data; + const {action_name} = logEntry; + + return createEntry({ + ...logEntry, + action_name, + action_phase: 'start', + log_entry_label: action_name, + start_timestamp: process.hrtime(), + }); +} + +function createActionEndEntry(logEntry: ActionStartLogEntry): LogEntry { + const { + action_name, + action_phase, + start_timestamp, + } = logEntry; + + if (action_phase !== 'start' || !Array.isArray(start_timestamp)) { + throw new Error('Action has not started or has already ended'); + } + + const timeDelta = process.hrtime(start_timestamp); + const duration_ms = Math.round((timeDelta[0] * 1e9 + timeDelta[1]) / 1e6); + + return createEntry({ + ...logEntry, + action_name, + action_phase: 'end', + duration_ms, + log_entry_label: action_name, + }); +} + +function log(logEntry: LogEntry): LogEntry { + eventEmitter.emit('log', logEntry); + return logEntry; +} + +module.exports = { + on, + createEntry, + createActionStartEntry, + createActionEndEntry, + log, +}; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/Graph.js b/packages/metro-bundler/react-packager/src/ModuleGraph/Graph.js new file mode 100644 index 00000000..27288c31 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/Graph.js @@ -0,0 +1,173 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const invariant = require('fbjs/lib/invariant'); +const memoize = require('async/memoize'); +const nullthrows = require('fbjs/lib/nullthrows'); +const queue = require('async/queue'); +const seq = require('async/seq'); + +import type { + Callback, + File, + GraphFn, + LoadFn, + ResolveFn, +} from './types.flow'; + +type Async$Queue = { + buffer: number, + concurrency: number, + drain: () => mixed, + empty: () => mixed, + error: (Error, T) => mixed, + idle(): boolean, + kill(): void, + length(): number, + pause(): void, + paused: boolean, + push(T | Array, void | C): void, + resume(): void, + running(): number, + saturated: () => mixed, + started: boolean, + unsaturated: () => mixed, + unshift(T, void | C): void, + workersList(): Array, +}; + +type LoadQueue = + Async$Queue<{id: string, parent: string}, Callback>>; + +const createParentModule = + () => ({file: {code: '', type: 'script', path: ''}, dependencies: []}); + +const noop = () => {}; +const NO_OPTIONS = {}; + +exports.create = function create(resolve: ResolveFn, load: LoadFn): GraphFn { + function Graph(entryPoints, platform, options, callback = noop) { + const { + cwd = '', + log = (console: any), + optimize = false, + skip, + } = options || NO_OPTIONS; + + if (typeof platform !== 'string') { + log.error('`Graph`, called without a platform'); + callback(Error('The target platform has to be passed')); + return; + } + + const loadQueue: LoadQueue = queue(seq( + ({id, parent}, cb) => resolve(id, parent, platform, options || NO_OPTIONS, cb), + memoize((file, cb) => load(file, {log, optimize}, cb)), + ), Number.MAX_SAFE_INTEGER); + + const {collect, loadModule} = createGraphHelpers(loadQueue, cwd, skip); + + loadQueue.drain = () => { + loadQueue.kill(); + callback(null, collect()); + }; + loadQueue.error = error => { + loadQueue.error = noop; + loadQueue.kill(); + callback(error); + }; + + let i = 0; + for (const entryPoint of entryPoints) { + loadModule(entryPoint, null, i++); + } + + if (i === 0) { + log.error('`Graph` called without any entry points'); + loadQueue.kill(); + callback(Error('At least one entry point has to be passed.')); + } + } + + return Graph; +}; + +function createGraphHelpers(loadQueue, cwd, skip) { + const modules = new Map([[null, createParentModule()]]); + + function collect( + path = null, + serialized = {entryModules: [], modules: []}, + seen = new Set(), + ) { + const module = modules.get(path); + if (module == null || seen.has(path)) { + return serialized; + } + + const {dependencies} = module; + if (path === null) { + serialized.entryModules = + dependencies.map(dep => nullthrows(modules.get(dep.path))); + } else { + serialized.modules.push(module); + seen.add(path); + } + + for (const dependency of dependencies) { + collect(dependency.path, serialized, seen); + } + + return serialized; + } + + function loadModule(id, parent, parentDepIndex) { + loadQueue.push( + {id, parent: parent != null ? parent : cwd}, + (error, file, dependencyIDs) => + onFileLoaded(error, file, dependencyIDs, id, parent, parentDepIndex), + ); + } + + function onFileLoaded( + error, + file, + dependencyIDs, + id, + parent, + parentDependencyIndex, + ) { + if (error) { + return; + } + + const {path} = nullthrows(file); + dependencyIDs = nullthrows(dependencyIDs); + + const parentModule = modules.get(parent); + invariant(parentModule, 'Invalid parent module: ' + String(parent)); + parentModule.dependencies[parentDependencyIndex] = {id, path}; + + if ((!skip || !skip.has(path)) && !modules.has(path)) { + const module = { + dependencies: Array(dependencyIDs.length), + file: nullthrows(file), + }; + modules.set(path, module); + for (let i = 0; i < dependencyIDs.length; ++i) { + loadModule(dependencyIDs[i], path, i); + } + } + } + + return {collect, loadModule}; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/ModuleGraph.js b/packages/metro-bundler/react-packager/src/ModuleGraph/ModuleGraph.js new file mode 100644 index 00000000..7c98b772 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/ModuleGraph.js @@ -0,0 +1,101 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const defaults = require('../../../defaults'); +const nullthrows = require('fbjs/lib/nullthrows'); +const parallel = require('async/parallel'); +const seq = require('async/seq'); + +const {virtualModule} = require('./output/util'); + +import type { + Callback, + GraphFn, + GraphResult, + Module, +} from './types.flow'; + +type BuildFn = ( + entryPoints: Iterable, + options: BuildOptions, + callback: Callback<{modules: Iterable, entryModules: Iterable}>, +) => void; + +type BuildOptions = {| + optimize?: boolean, + platform?: string, +|}; + +exports.createBuildSetup = ( + graph: GraphFn, + translateDefaultsPath: string => string = x => x, +): BuildFn => + (entryPoints, options, callback) => { + const { + optimize = false, + platform = defaults.platforms[0], + } = options; + const graphOptions = {optimize}; + + const graphWithOptions = + (entry, cb) => graph(entry, platform, graphOptions, cb); + const graphOnlyModules = seq(graphWithOptions, getModules); + + parallel({ + graph: cb => graphWithOptions( + concat(defaults.runBeforeMainModule, entryPoints), + cb, + ), + moduleSystem: cb => graphOnlyModules( + [translateDefaultsPath(defaults.moduleSystem)], + cb, + ), + polyfills: cb => graphOnlyModules( + defaults.polyfills.map(translateDefaultsPath), + cb, + ), + }, ( + error: ?Error, + result?: {graph: GraphResult, moduleSystem: Array, polyfills: Array}, + ) => { + if (error) { + callback(error); + return; + } + + + const { + graph: {modules, entryModules}, + moduleSystem, + polyfills, + } = nullthrows(result); + + callback(null, { + entryModules, + modules: concat([prelude(optimize)], moduleSystem, polyfills, modules), + }); + }); + }; + +const getModules = (x, cb) => cb(null, x.modules); + +function* concat(...iterables: Array>): Iterable { + for (const it of iterables) { + yield* it; + } +} + +function prelude(optimize) { + return virtualModule( + `var __DEV__=${String(!optimize)},__BUNDLE_START_TIME__=Date.now();` + ); +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/Graph-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/Graph-test.js new file mode 100644 index 00000000..2fede7c4 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/Graph-test.js @@ -0,0 +1,376 @@ +/** + * 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'; + +jest + .disableAutomock() + .useRealTimers() + .mock('console'); + +const {Console} = require('console'); +const Graph = require('../Graph'); +const {fn} = require('../test-helpers'); + +const {any, objectContaining} = jasmine; +const quiet = new Console(); + +describe('Graph:', () => { + const anyEntry = ['arbitrary/entry/point']; + const anyPlatform = 'arbitrary platform'; + const noOpts = undefined; + + let graph, load, resolve; + beforeEach(() => { + load = fn(); + resolve = fn(); + resolve.stub.yields(null, 'arbitrary file'); + load.stub.yields(null, createFile('arbitrary file'), []); + + graph = Graph.create(resolve, load); + }); + + it('calls back an error when called without any entry point', done => { + graph([], anyPlatform, {log: quiet}, (error) => { + expect(error).toEqual(any(Error)); + done(); + }); + }); + + it('resolves the entry point with the passed-in `resolve` function', done => { + const entryPoint = '/arbitrary/path'; + graph([entryPoint], anyPlatform, noOpts, () => { + expect(resolve).toBeCalledWith( + entryPoint, '', any(String), any(Object), any(Function)); + done(); + }); + }); + + it('allows to specify multiple entry points', done => { + const entryPoints = ['Arbitrary', '../entry.js']; + graph(entryPoints, anyPlatform, noOpts, () => { + expect(resolve).toBeCalledWith( + entryPoints[0], '', any(String), any(Object), any(Function)); + expect(resolve).toBeCalledWith( + entryPoints[1], '', any(String), any(Object), any(Function)); + done(); + }); + + }); + + it('calls back with an error when called without `platform` option', done => { + graph(anyEntry, undefined, {log: quiet}, error => { + expect(error).toEqual(any(Error)); + done(); + }); + }); + + it('forwards a passed-in `platform` to `resolve`', done => { + const platform = 'any'; + graph(anyEntry, platform, noOpts, () => { + expect(resolve).toBeCalledWith( + any(String), '', platform, any(Object), any(Function)); + done(); + }); + }); + + it('forwards a passed-in `log` option to `resolve`', done => { + const log = new Console(); + graph(anyEntry, anyPlatform, {log}, () => { + expect(resolve).toBeCalledWith( + any(String), '', any(String), objectContaining({log}), any(Function)); + done(); + }); + }); + + it('calls back with every error produced by `resolve`', done => { + const error = Error(); + resolve.stub.yields(error); + graph(anyEntry, anyPlatform, noOpts, e => { + expect(e).toBe(error); + done(); + }); + }); + + it('only calls back once if two parallel invocations of `resolve` fail', done => { + load.stub.yields(null, createFile('with two deps'), ['depA', 'depB']); + resolve.stub + .withArgs('depA').yieldsAsync(new Error()) + .withArgs('depB').yieldsAsync(new Error()); + + let calls = 0; + function callback() { + if (calls === 0) { + process.nextTick(() => { + expect(calls).toEqual(1); + done(); + }); + } + ++calls; + } + + graph(['entryA', 'entryB'], anyPlatform, noOpts, callback); + }); + + it('passes the files returned by `resolve` on to the `load` function', done => { + const modules = new Map([ + ['Arbitrary', '/absolute/path/to/Arbitrary.js'], + ['../entry.js', '/whereever/is/entry.js'], + ]); + for (const [id, file] of modules) { + resolve.stub.withArgs(id).yields(null, file); + } + const [file1, file2] = modules.values(); + + graph(modules.keys(), anyPlatform, noOpts, () => { + expect(load).toBeCalledWith(file1, any(Object), any(Function)); + expect(load).toBeCalledWith(file2, any(Object), any(Function)); + done(); + }); + }); + + it('passes the `optimize` flag on to `load`', done => { + graph(anyEntry, anyPlatform, {optimize: true}, () => { + expect(load).toBeCalledWith( + any(String), objectContaining({optimize: true}), any(Function)); + done(); + }); + }); + + it('uses `false` as the default for the `optimize` flag', done => { + graph(anyEntry, anyPlatform, noOpts, () => { + expect(load).toBeCalledWith( + any(String), objectContaining({optimize: false}), any(Function)); + done(); + }); + }); + + it('forwards a passed-in `log` to `load`', done => { + const log = new Console(); + graph(anyEntry, anyPlatform, {log}, () => { + expect(load) + .toBeCalledWith(any(String), objectContaining({log}), any(Function)); + done(); + }); + }); + + it('calls back with every error produced by `load`', done => { + const error = Error(); + load.stub.yields(error); + graph(anyEntry, anyPlatform, noOpts, e => { + expect(e).toBe(error); + done(); + }); + }); + + it('resolves any dependencies provided by `load`', done => { + const entryPath = '/path/to/entry.js'; + const id1 = 'required/id'; + const id2 = './relative/import'; + resolve.stub.withArgs('entry').yields(null, entryPath); + load.stub.withArgs(entryPath) + .yields(null, {path: entryPath}, [id1, id2]); + + graph(['entry'], anyPlatform, noOpts, () => { + expect(resolve).toBeCalledWith( + id1, entryPath, any(String), any(Object), any(Function)); + expect(resolve).toBeCalledWith( + id2, entryPath, any(String), any(Object), any(Function)); + done(); + }); + }); + + it('loads transitive dependencies', done => { + const entryPath = '/path/to/entry.js'; + const id1 = 'required/id'; + const id2 = './relative/import'; + const path1 = '/path/to/dep/1'; + const path2 = '/path/to/dep/2'; + + resolve.stub + .withArgs(id1).yields(null, path1) + .withArgs(id2).yields(null, path2) + .withArgs('entry').yields(null, entryPath); + load.stub + .withArgs(entryPath).yields(null, {path: entryPath}, [id1]) + .withArgs(path1).yields(null, {path: path1}, [id2]); + + graph(['entry'], anyPlatform, noOpts, () => { + expect(resolve).toBeCalledWith(id2, path1, any(String), any(Object), any(Function)); + expect(load).toBeCalledWith(path1, any(Object), any(Function)); + expect(load).toBeCalledWith(path2, any(Object), any(Function)); + done(); + }); + }); + + it('calls back with an array of modules in depth-first traversal order, regardless of the order of resolution', done => { + load.stub.reset(); + resolve.stub.reset(); + + const ids = [ + 'a', + 'b', + 'c', 'd', + 'e', + 'f', 'g', + 'h', + ]; + ids.forEach(id => { + const path = idToPath(id); + resolve.stub.withArgs(id).yields(null, path); + load.stub.withArgs(path).yields(null, createFile(id), []); + }); + load.stub.withArgs(idToPath('a')).yields(null, createFile('a'), ['b', 'e', 'h']); + load.stub.withArgs(idToPath('b')).yields(null, createFile('b'), ['c', 'd']); + load.stub.withArgs(idToPath('e')).yields(null, createFile('e'), ['f', 'g']); + + // load certain ids later + ['b', 'e', 'h'].forEach(id => resolve.stub.withArgs(id).resetBehavior()); + resolve.stub.withArgs('h').func = (a, b, c, d, callback) => { + callback(null, idToPath('h')); + ['e', 'b'].forEach( + id => resolve.stub.withArgs(id).yield(null, idToPath(id))); + }; + + graph(['a'], anyPlatform, noOpts, (error, result) => { + expect(error).toEqual(null); + expect(result.modules).toEqual([ + createModule('a', ['b', 'e', 'h']), + createModule('b', ['c', 'd']), + createModule('c'), + createModule('d'), + createModule('e', ['f', 'g']), + createModule('f'), + createModule('g'), + createModule('h'), + ]); + done(); + }); + }); + + it('calls back with the resolved modules of the entry points', done => { + load.stub.reset(); + resolve.stub.reset(); + + load.stub.withArgs(idToPath('a')).yields(null, createFile('a'), ['b']); + load.stub.withArgs(idToPath('b')).yields(null, createFile('b'), []); + load.stub.withArgs(idToPath('c')).yields(null, createFile('c'), ['d']); + load.stub.withArgs(idToPath('d')).yields(null, createFile('d'), []); + + 'abcd'.split('') + .forEach(id => resolve.stub.withArgs(id).yields(null, idToPath(id))); + + graph(['a', 'c'], anyPlatform, noOpts, (error, result) => { + expect(result.entryModules).toEqual([ + createModule('a', ['b']), + createModule('c', ['d']), + ]); + done(); + }); + }); + + it('calls back with the resolved modules of the entry points if one entry point is a dependency of another', done => { + load.stub.reset(); + resolve.stub.reset(); + + load.stub.withArgs(idToPath('a')).yields(null, createFile('a'), ['b']); + load.stub.withArgs(idToPath('b')).yields(null, createFile('b'), []); + + 'ab'.split('') + .forEach(id => resolve.stub.withArgs(id).yields(null, idToPath(id))); + + graph(['a', 'b'], anyPlatform, noOpts, (error, result) => { + expect(result.entryModules).toEqual([ + createModule('a', ['b']), + createModule('b', []), + ]); + done(); + }); + }); + + it('does not include dependencies more than once', done => { + const ids = ['a', 'b', 'c', 'd']; + ids.forEach(id => { + const path = idToPath(id); + resolve.stub.withArgs(id).yields(null, path); + load.stub.withArgs(path).yields(null, createFile(id), []); + }); + ['a', 'd'].forEach(id => + load.stub + .withArgs(idToPath(id)).yields(null, createFile(id), ['b', 'c'])); + + graph(['a', 'd', 'b'], anyPlatform, noOpts, (error, result) => { + expect(error).toEqual(null); + expect(result.modules).toEqual([ + createModule('a', ['b', 'c']), + createModule('b'), + createModule('c'), + createModule('d', ['b', 'c']), + ]); + done(); + }); + }); + + it('handles dependency cycles', done => { + resolve.stub + .withArgs('a').yields(null, idToPath('a')) + .withArgs('b').yields(null, idToPath('b')) + .withArgs('c').yields(null, idToPath('c')); + load.stub + .withArgs(idToPath('a')).yields(null, createFile('a'), ['b']) + .withArgs(idToPath('b')).yields(null, createFile('b'), ['c']) + .withArgs(idToPath('c')).yields(null, createFile('c'), ['a']); + + graph(['a'], anyPlatform, noOpts, (error, result) => { + expect(result.modules).toEqual([ + createModule('a', ['b']), + createModule('b', ['c']), + createModule('c', ['a']), + ]); + done(); + }); + }); + + it('can skip files', done => { + ['a', 'b', 'c', 'd', 'e'].forEach( + id => resolve.stub.withArgs(id).yields(null, idToPath(id))); + load.stub + .withArgs(idToPath('a')).yields(null, createFile('a'), ['b', 'c', 'd']) + .withArgs(idToPath('b')).yields(null, createFile('b'), ['e']); + ['c', 'd', 'e'].forEach(id => + load.stub.withArgs(idToPath(id)).yields(null, createFile(id), [])); + const skip = new Set([idToPath('b'), idToPath('c')]); + + graph(['a'], anyPlatform, {skip}, (error, result) => { + expect(result.modules).toEqual([ + createModule('a', ['b', 'c', 'd']), + createModule('d', []), + ]); + done(); + }); + }); +}); + +function createDependency(id) { + return {id, path: idToPath(id)}; +} + +function createFile(id) { + return {ast: {}, path: idToPath(id)}; +} + +function createModule(id, dependencies = []): Module { + return { + file: createFile(id), + dependencies: dependencies.map(createDependency) + }; +} + +function idToPath(id) { + return '/path/to/' + id; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/ModuleGraph-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/ModuleGraph-test.js new file mode 100644 index 00000000..9b0f8e85 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/__tests__/ModuleGraph-test.js @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2017-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.disableAutomock(); + +const ModuleGraph = require('../ModuleGraph'); +const defaults = require('../../../../defaults'); + +const FILE_TYPE = 'module'; + +describe('build setup', () => { + const buildSetup = ModuleGraph.createBuildSetup(graph); + const noOptions = {}; + const noEntryPoints = []; + + it('adds a prelude containing start time and `__DEV__` to the build', done => { + buildSetup(noEntryPoints, noOptions, (error, result) => { + expect(error).toEqual(null); + + const [prelude] = result.modules; + expect(prelude).toEqual({ + dependencies: [], + file: { + code: 'var __DEV__=true,__BUNDLE_START_TIME__=Date.now();', + path: '', + type: 'script', + }, + }); + done(); + }); + }); + + it('sets `__DEV__` to false in the prelude if optimization is enabled', done => { + buildSetup(noEntryPoints, {optimize: true}, (error, result) => { + const [prelude] = result.modules; + expect(prelude.file.code) + .toEqual('var __DEV__=false,__BUNDLE_START_TIME__=Date.now();'); + done(); + }); + }); + + it('places the module system implementation directly after the prelude', done => { + buildSetup(noEntryPoints, noOptions, (error, result) => { + const [, moduleSystem] = result.modules; + expect(moduleSystem).toEqual({ + dependencies: [], + file: { + code: '', + path: defaults.moduleSystem, + type: FILE_TYPE, + }, + }); + done(); + }); + }); + + it('places polyfills after the module system', done => { + buildSetup(noEntryPoints, noOptions, (error, result) => { + const polyfills = + Array.from(result.modules).slice(2, 2 + defaults.polyfills.length); + expect(polyfills).toEqual(defaults.polyfills.map(moduleFromPath)); + done(); + }); + }); + + it('places all modules from `defaults.runBeforeMainModule` after the polyfills', done => { + buildSetup(noEntryPoints, noOptions, (error, result) => { + const additionalModules = + Array.from(result.modules).slice(-defaults.runBeforeMainModule.length); + expect(additionalModules) + .toEqual(defaults.runBeforeMainModule.map(moduleFromPath)); + done(); + }); + }); + + it('places all entry points at the end', done => { + const entryPoints = ['a', 'b', 'c']; + buildSetup(entryPoints, noOptions, (error, result) => { + expect(Array.from(result.modules).slice(-3)) + .toEqual(entryPoints.map(moduleFromPath)); + done(); + }); + }); + + it('concatenates `runBeforeMainModule` and entry points as `entryModules`', done => { + const entryPoints = ['a', 'b', 'c']; + buildSetup(entryPoints, noOptions, (error, result) => { + expect(Array.from(result.entryModules)).toEqual( + defaults.runBeforeMainModule.concat(entryPoints).map(moduleFromPath)); + done(); + }); + }); +}); + +function moduleFromPath(path) { + return { + dependencies: [], + file: { + code: '', + path, + type: FILE_TYPE, + }, + }; +} + +function graph(entryPoints, platform, options, callback) { + const modules = Array.from(entryPoints, moduleFromPath); + callback(null, { + entryModules: modules, + modules, + }); +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/HasteFS.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/HasteFS.js new file mode 100644 index 00000000..7d31413c --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/HasteFS.js @@ -0,0 +1,80 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const {dirname, join, parse} = require('path'); + +module.exports = class HasteFS { + directories: Set; + directoryEntries: Map>; + files: Set; + + constructor(files: Array) { + this.directories = buildDirectorySet(files); + this.directoryEntries = buildDirectoryEntries(files.map(parse)); + this.files = new Set(files); + } + + closest(path: string, fileName: string): ?string { + let {dir, root} = parse(path); + do { + const candidate = join(dir, fileName); + if (this.files.has(candidate)) { + return candidate; + } + dir = dirname(dir); + } while (dir !== '.' && dir !== root); + return null; + } + + dirExists(path: string) { + return this.directories.has(path); + } + + exists(path: string) { + return this.files.has(path); + } + + getAllFiles() { + return Array.from(this.files.keys()); + } + + matches(directory: string, pattern: RegExp) { + const entries = this.directoryEntries.get(directory); + return entries ? entries.filter(pattern.test, pattern) : []; + } +}; + +function buildDirectorySet(files) { + const directories = new Set(); + files.forEach(path => { + let {dir, root} = parse(path); + while (dir !== '.' && dir !== root && !directories.has(dir)) { + directories.add(dir); + dir = dirname(dir); + } + }); + return directories; +} + +function buildDirectoryEntries(files) { + const directoryEntries = new Map(); + files.forEach(({base, dir}) => { + const entries = directoryEntries.get(dir); + if (entries) { + entries.push(base); + } else { + directoryEntries.set(dir, [base]); + } + }); + return directoryEntries; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Module.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Module.js new file mode 100644 index 00000000..13719de0 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Module.js @@ -0,0 +1,51 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +import type {TransformedFile} from '../types.flow'; +import type {ModuleCache} from './node-haste.flow'; + +module.exports = class Module { + hasteID: Promise; + moduleCache: ModuleCache; + name: Promise; + path: string; + type: 'Module'; + + constructor( + path: string, + moduleCache: ModuleCache, + info: Promise, + ) { + this.hasteID = info.then(({hasteID}) => hasteID); + this.moduleCache = moduleCache; + this.name = this.hasteID.then(name => name || getName(path)); + this.path = path; + this.type = 'Module'; + } + + getName() { + return this.name; + } + + getPackage() { + return this.moduleCache.getPackageOf(this.path); + } + + isHaste() { + return this.hasteID.then(Boolean); + } +}; + +function getName(path) { + return path.replace(/^.*[\/\\]node_modules[\///]/, ''); +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/ModuleCache.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/ModuleCache.js new file mode 100644 index 00000000..bde9d0df --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/ModuleCache.js @@ -0,0 +1,65 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Module = require('./Module'); +const Package = require('./Package'); + +import type {PackageData, TransformedFile} from '../types.flow'; + +type GetFn = (path: string) => Promise; +type GetClosestPackageFn = (filePath: string) => ?string; + +module.exports = class ModuleCache { + _getClosestPackage: GetClosestPackageFn; + getPackageData: GetFn; + getTransformedFile: GetFn; + modules: Map; + packages: Map; + + constructor(getClosestPackage: GetClosestPackageFn, getTransformedFile: GetFn) { + this._getClosestPackage = getClosestPackage; + this.getTransformedFile = getTransformedFile; + this.getPackageData = path => getTransformedFile(path).then( + f => f.package || Promise.reject(new Error(`"${path}" does not exist`)) + ); + this.modules = new Map(); + this.packages = new Map(); + } + + getAssetModule(path: string) { + return this.getModule(path); + } + + getModule(path: string) { + let m = this.modules.get(path); + if (!m) { + m = new Module(path, this, this.getTransformedFile(path)); + this.modules.set(path, m); + } + return m; + } + + getPackage(path: string) { + let p = this.packages.get(path); + if (!p) { + p = new Package(path, this.getPackageData(path)); + this.packages.set(path, p); + } + return p; + } + + getPackageOf(filePath: string) { + const candidate = this._getClosestPackage(filePath); + return candidate != null ? this.getPackage(candidate) : null; + } +}; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Package.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Package.js new file mode 100644 index 00000000..ad328d7a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/Package.js @@ -0,0 +1,138 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const path = require('path'); + +import type {PackageData} from '../types.flow'; + +module.exports = class Package { + data: Promise; + path: string; + root: string; + type: 'Package'; + + constructor(packagePath: string, data: Promise) { + this.data = data; + this.path = packagePath; + this.root = path.dirname(packagePath); + this.type = 'Package'; + } + + getMain() { + // Copied from node-haste/Package.js + return this.data.then(data => { + const replacements = getReplacements(data); + if (typeof replacements === 'string') { + return path.join(this.root, replacements); + } + + let main = getMain(data); + + if (replacements && typeof replacements === 'object') { + main = replacements[main] || + replacements[main + '.js'] || + replacements[main + '.json'] || + replacements[main.replace(/(\.js|\.json)$/, '')] || + main; + } + + return path.join(this.root, main); + }); + } + + getName() { + return this.data.then(p => p.name); + } + + isHaste() { + return this.data.then(p => !!p.name); + } + + redirectRequire(name: string) { + // Copied from node-haste/Package.js + return this.data.then(data => { + const replacements = getReplacements(data); + + if (!replacements || typeof replacements !== 'object') { + return name; + } + + if (!path.isAbsolute(name)) { + const replacement = replacements[name]; + // support exclude with "someDependency": false + return replacement === false + ? false + : replacement || name; + } + + let relPath = './' + path.relative(this.root, name); + if (path.sep !== '/') { + relPath = relPath.replace(new RegExp('\\' + path.sep, 'g'), '/'); + } + + let redirect = replacements[relPath]; + + // false is a valid value + if (redirect == null) { + redirect = replacements[relPath + '.js']; + if (redirect == null) { + redirect = replacements[relPath + '.json']; + } + } + + // support exclude with "./someFile": false + if (redirect === false) { + return false; + } + + if (redirect) { + return path.join( + this.root, + redirect + ); + } + + return name; + }); + } +}; + +function getMain(pkg) { + return pkg.main || 'index'; +} + +// Copied from node-haste/Package.js +function getReplacements(pkg) { + let rn = pkg['react-native']; + let browser = pkg.browser; + if (rn == null) { + return browser; + } + + if (browser == null) { + return rn; + } + + const main = getMain(pkg); + if (typeof rn !== 'object') { + rn = { [main]: rn }; + } + + if (typeof browser !== 'object') { + browser = { [main]: browser }; + } + + // merge with "browser" as default, + // "react-native" as override + return { ...browser, ...rn }; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.flow.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.flow.js new file mode 100644 index 00000000..a420bf6b --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.flow.js @@ -0,0 +1,71 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +'use strict'; + +import DependencyGraphHelpers from '../../node-haste/DependencyGraph/DependencyGraphHelpers'; + +type ModuleID = string; +export type Path = string; +type Platform = string; +type Platforms = Set; + +export type Extensions = Array; + +export type Module = { + path: Path, + type: 'Module', + getName(): Promise, + getPackage(): ?Package, + isHaste(): Promise, +}; + +export type Package = { + path: Path, + root: Path, + type: 'Package', + getMain(): Promise, + getName(): Promise, + isHaste(): Promise, + redirectRequire(id: ModuleID): Promise, +}; + +// when changing this to `type`, the code does not typecheck any more +export interface ModuleCache { + getAssetModule(path: Path): Module, + getModule(path: Path): Module, + getPackage(path: Path): Package, + getPackageOf(path: Path): ?Package, +} + +export type FastFS = { + dirExists(path: Path): boolean, + closest(path: string, fileName: string): ?string, + fileExists(path: Path): boolean, + getAllFiles(): Array, + matches(directory: Path, pattern: RegExp): Array, +}; + +type HasteMapOptions = {| + extensions: Extensions, + files: Array, + helpers: DependencyGraphHelpers, + moduleCache: ModuleCache, + platforms: Platforms, + preferNativePlatform: true, +|}; + +declare class HasteMap { + // node-haste/DependencyGraph/HasteMap.js + build(): Promise, + constructor(options: HasteMapOptions): void, +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.js b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.js new file mode 100644 index 00000000..ce1b08fd --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/node-haste.js @@ -0,0 +1,101 @@ +/** + * 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. + * + * @flow + */ + + 'use strict'; + +import type { // eslint-disable-line sort-requires + Extensions, + Path, +} from './node-haste.flow'; + +import type { + ResolveFn, + TransformedFile, +} from '../types.flow'; + +const DependencyGraphHelpers = require('../../node-haste/DependencyGraph/DependencyGraphHelpers'); +const HasteFS = require('./HasteFS'); +const HasteMap = require('../../node-haste/DependencyGraph/HasteMap'); +const Module = require('./Module'); +const ModuleCache = require('./ModuleCache'); +const ResolutionRequest = require('../../node-haste/DependencyGraph/ResolutionRequest'); + +const defaults = require('../../../../defaults'); + +type ResolveOptions = {| + assetExts: Extensions, + extraNodeModules: {[id: string]: string}, + transformedFiles: {[path: Path]: TransformedFile}, +|}; + +const platforms = new Set(defaults.platforms); + +exports.createResolveFn = function(options: ResolveOptions): ResolveFn { + const { + assetExts, + extraNodeModules, + transformedFiles, + } = options; + const files = Object.keys(transformedFiles); + const getTransformedFile = + path => Promise.resolve( + transformedFiles[path] || Promise.reject(new Error(`"${path} does not exist`)) + ); + + const helpers = new DependencyGraphHelpers({ + assetExts, + providesModuleNodeModules: defaults.providesModuleNodeModules, + }); + + const hasteFS = new HasteFS(files); + const moduleCache = new ModuleCache( + filePath => hasteFS.closest(filePath, 'package.json'), + getTransformedFile, + ); + const hasteMap = new HasteMap({ + extensions: ['js', 'json'], + files, + helpers, + moduleCache, + platforms, + preferNativePlatform: true, + }); + + const hasteMapBuilt = hasteMap.build(); + const resolutionRequests = {}; + return (id, source, platform, _, callback) => { + let resolutionRequest = resolutionRequests[platform]; + if (!resolutionRequest) { + resolutionRequest = resolutionRequests[platform] = new ResolutionRequest({ + dirExists: filePath => hasteFS.dirExists(filePath), + entryPath: '', + extraNodeModules, + /* $FlowFixMe: object is missing matchFiles method */ + hasteFS, + hasteMap, + helpers, + moduleCache, + platform, + platforms, + preferNativePlatform: true, + }); + } + + const from = new Module(source, moduleCache, getTransformedFile(source)); + hasteMapBuilt + .then(() => resolutionRequest.resolveDependency(from, id)) + .then( + // nextTick to escape promise error handling + module => process.nextTick(callback, null, module.path), + error => process.nextTick(callback, error), + ); + }; +}; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/package.json b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/package.json new file mode 100644 index 00000000..f642436d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/node-haste/package.json @@ -0,0 +1 @@ +{"main":"node-haste.js"} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/output/__tests__/util-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/output/__tests__/util-test.js new file mode 100644 index 00000000..6984b76f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/output/__tests__/util-test.js @@ -0,0 +1,83 @@ +/** + * 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'; + +jest.disableAutomock(); + +const {match} = require('sinon'); +const {fn} = require('../../test-helpers'); +const { + addModuleIdsToModuleWrapper, + createIdForPathFn, +} = require('../util'); + +const {any} = jasmine; + +describe('`addModuleIdsToModuleWrapper`:', () => { + const path = 'path/to/file'; + const createModule = (dependencies = []) => ({ + dependencies, + file: {code: '__d(function(){});', isModule: true, path}, + }); + + it('completes the module wrapped with module ID, and an array of dependency IDs', () => { + const dependencies = [ + {id: 'a', path: 'path/to/a.js'}, + {id: 'b', path: 'location/of/b.js'}, + ]; + const module = createModule(dependencies); + + const idForPath = fn(); + idForPath.stub + .withArgs(match({path})).returns(12) + .withArgs(match({path: dependencies[0].path})).returns(345) + .withArgs(match({path: dependencies[1].path})).returns(6); + + expect(addModuleIdsToModuleWrapper(module, idForPath)) + .toEqual('__d(function(){},12,[345,6]);'); + }); + + it('omits the array of dependency IDs if it is empty', () => { + const module = createModule(); + expect(addModuleIdsToModuleWrapper(module, () => 98)) + .toEqual(`__d(function(){},${98});`); + }); +}); + +describe('`createIdForPathFn`', () => { + let idForPath; + beforeEach(() => { + idForPath = createIdForPathFn(); + }); + + it('returns a number for a string', () => { + expect(idForPath({path: 'arbitrary'})).toEqual(any(Number)); + }); + + it('returns consecutive numbers', () => { + const strings = [ + 'arbitrary string', + 'looking/like/a/path', + '/absolute/path/to/file.js', + '/more files/are here', + ]; + + strings.forEach((string, i) => { + expect(idForPath({path: string})).toEqual(i); + }); + }); + + it('returns the same id if the same string is passed in again', () => { + const path = 'this/is/an/arbitrary/path.js'; + const id = idForPath({path}); + idForPath({path: '/other/file'}); + idForPath({path: 'and/another/file'}); + expect(idForPath({path})).toEqual(id); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/output/as-plain-bundle.js b/packages/metro-bundler/react-packager/src/ModuleGraph/output/as-plain-bundle.js new file mode 100644 index 00000000..2cf40dde --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/output/as-plain-bundle.js @@ -0,0 +1,47 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const {createIndexMap} = require('./source-map'); +const {addModuleIdsToModuleWrapper} = require('./util'); + +import type {OutputFn} from '../types.flow'; + +module.exports = ( + (modules, filename, idForPath) => { + let code = ''; + let line = 0; + const sections = []; + + for (const module of modules) { + const {file} = module; + const moduleCode = file.type === 'module' + ? addModuleIdsToModuleWrapper(module, idForPath) + : file.code; + + code += moduleCode + '\n'; + if (file.map) { + sections.push({ + map: file.map, + offset: {column: 0, line} + }); + } + line += countLines(moduleCode); + } + + return {code, map: createIndexMap({file: filename, sections})}; + }: OutputFn); + +const reLine = /^/gm; +function countLines(string: string): number { + //$FlowFixMe This regular expression always matches + return string.match(reLine).length; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/output/source-map.js b/packages/metro-bundler/react-packager/src/ModuleGraph/output/source-map.js new file mode 100644 index 00000000..bade8fec --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/output/source-map.js @@ -0,0 +1,48 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +type CreateIndexMapOptions = {| + file?: string, + sections?: Array +|}; + +type IndexMap = MapBase & { + sections: Array, +}; + +type IndexMapSection = { + map: IndexMap | MappingsMap, + offset: {line: number, column: number}, +}; + +type MapBase = { + // always the first entry in the source map entry object per + // https://fburl.com/source-map-spec#heading=h.qz3o9nc69um5 + version: 3, + file?: string, +}; + +type MappingsMap = MapBase & { + mappings: string, + names: Array, + sourceRoot?: string, + sources: Array, + sourcesContent?: Array, +}; + +export type SourceMap = IndexMap | MappingsMap; + +exports.createIndexMap = (opts?: CreateIndexMapOptions): IndexMap => ({ + version: 3, + file: opts && opts.file, + sections: opts && opts.sections || [], +}); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/output/util.js b/packages/metro-bundler/react-packager/src/ModuleGraph/output/util.js new file mode 100644 index 00000000..d233817e --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/output/util.js @@ -0,0 +1,85 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {IdForPathFn, Module} from '../types.flow'; + +// Transformed modules have the form +// __d(function(require, module, global, exports, dependencyMap) { +// /* code */ +// }); +// +// This function adds the numeric module ID, and an array with dependencies of +// the dependencies of the module before the closing parenthesis. +exports.addModuleIdsToModuleWrapper = ( + module: Module, + idForPath: {path: string} => number, +): string => { + const {dependencies, file} = module; + const {code} = file; + const index = code.lastIndexOf(')'); + + // calling `idForPath` on the module itself first gives us a lower module id + // for the file itself than for its dependencies. That reflects their order + // in the bundle. + const fileId = idForPath(file); + + // This code runs for both development and production builds, after + // minification. That's why we leave out all spaces. + const depencyIds = + dependencies.length ? `,[${dependencies.map(idForPath).join(',')}]` : ''; + return ( + code.slice(0, index) + + `,${fileId}` + + depencyIds + + code.slice(index) + ); +}; + +// Creates an idempotent function that returns numeric IDs for objects based +// on their `path` property. +exports.createIdForPathFn = (): ({path: string} => number) => { + const seen = new Map(); + let next = 0; + return ({path}) => { + let id = seen.get(path); + if (id == null) { + id = next++; + seen.set(path, id); + } + return id; + }; +}; + +// creates a series of virtual modules with require calls to the passed-in +// modules. +exports.requireCallsTo = function* ( + modules: Iterable, + idForPath: IdForPathFn, +): Iterable { + for (const module of modules) { + yield virtualModule(`require(${idForPath(module.file)});`); + } +}; + +// creates a virtual module (i.e. not corresponding to a file on disk) +// with the given source code. +exports.virtualModule = virtualModule; +function virtualModule(code: string) { + return { + dependencies: [], + file: { + code, + path: '', + type: 'script', + } + }; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/package.json b/packages/metro-bundler/react-packager/src/ModuleGraph/package.json new file mode 100644 index 00000000..4c0d77b8 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/package.json @@ -0,0 +1 @@ +{"main": "ModuleGraph.js"} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/silent-console.js b/packages/metro-bundler/react-packager/src/ModuleGraph/silent-console.js new file mode 100644 index 00000000..b738ddaf --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/silent-console.js @@ -0,0 +1,15 @@ +/** + * 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 {Console} = require('console'); +const {Writable} = require('stream'); + +const write = (_, __, callback) => callback(); +module.exports = new Console(new Writable({write, writev: write})); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/test-helpers.js b/packages/metro-bundler/react-packager/src/ModuleGraph/test-helpers.js new file mode 100644 index 00000000..35deb46a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/test-helpers.js @@ -0,0 +1,23 @@ +/** + * 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 generate = require('babel-generator').default; +const stub = require('sinon/lib/sinon/stub'); + +exports.fn = () => { + const s = stub(); + const f = jest.fn(s); + f.stub = s; + return f; +}; + +const generateOptions = {concise: true}; +exports.codeFromAst = ast => generate(ast, generateOptions).code; +exports.comparableCode = code => code.trim().replace(/\s\s+/g, ' '); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/types.flow.js b/packages/metro-bundler/react-packager/src/ModuleGraph/types.flow.js new file mode 100644 index 00000000..fabb48f2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/types.flow.js @@ -0,0 +1,134 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +import type {SourceMap} from './output/source-map'; +import type {Console} from 'console'; + +export type Callback + = (Error => void) + & ((null | void, A, B) => void); + +type Dependency = {| + id: string, + path: string, +|}; + +export type File = {| + code: string, + map?: ?Object, + path: string, + type: FileTypes, +|}; + +type FileTypes = 'module' | 'script'; + +export type GraphFn = ( + entryPoints: Iterable, + platform: string, + options?: ?GraphOptions, + callback?: Callback, +) => void; + +type GraphOptions = {| + cwd?: string, + log?: Console, + optimize?: boolean, + skip?: Set, +|}; + +export type GraphResult = { + entryModules: Array, + modules: Array, +}; + +export type IdForPathFn = {path: string} => number; + +export type LoadFn = ( + file: string, + options: LoadOptions, + callback: Callback>, +) => void; + +type LoadOptions = {| + log?: Console, + optimize?: boolean, + platform?: string, +|}; + +export type Module = {| + dependencies: Array, + file: File, +|}; + +export type OutputFn = ( + modules: Iterable, + filename?: string, + idForPath: IdForPathFn, +) => OutputResult; + +type OutputResult = { + code: string, + map: SourceMap, +}; + +export type PackageData = {| + browser?: Object | string, + main?: string, + name?: string, + 'react-native'?: Object | string, +|}; + +export type ResolveFn = ( + id: string, + source: string, + platform: string, + options?: ResolveOptions, + callback: Callback, +) => void; + +type ResolveOptions = { + log?: Console, +}; + +export type TransformFn = ( + data: {| + filename: string, + options?: Object, + plugins?: Array, + sourceCode: string, + |}, + callback: Callback +) => void; + +export type TransformFnResult = { + ast: Object, +}; + +export type TransformResult = {| + code: string, + dependencies: Array, + dependencyMapName?: string, + map: ?Object, +|}; + +export type TransformResults = {[string]: TransformResult}; + +export type TransformVariants = {[key: string]: Object}; + +export type TransformedFile = { + code: string, + file: string, + hasteID: ?string, + package?: PackageData, + transformed: TransformResults, + type: FileTypes, +}; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker.js new file mode 100644 index 00000000..96c00f6e --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker.js @@ -0,0 +1,25 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const asyncify = require('async/asyncify'); +const optimizeModule = require('./worker/optimize-module'); +const transformModule = require('./worker/transform-module'); +const wrapWorkerFn = require('./worker/wrap-worker-fn'); + +import type {OptimizationOptions} from './worker/optimize-module'; +import type {TransformOptions} from './worker/transform-module'; +import type {WorkerFnWithIO} from './worker/wrap-worker-fn'; + +exports.optimizeModule = + (wrapWorkerFn(asyncify(optimizeModule)): WorkerFnWithIO); +exports.transformModule = + (wrapWorkerFn(transformModule): WorkerFnWithIO); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/collect-dependencies-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/collect-dependencies-test.js new file mode 100644 index 00000000..72d9a6f3 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/collect-dependencies-test.js @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2017-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.disableAutomock(); + +const collectDependencies = require('../collect-dependencies'); +const astFromCode = require('babylon').parse; +const {codeFromAst, comparableCode} = require('../../test-helpers'); + +const {any} = expect; + +describe('dependency collection from ASTs:', () => { + it('collects dependency identifiers from the code', () => { + const ast = astFromCode(` + const a = require('b/lib/a'); + exports.do = () => require("do"); + if (!something) { + require("setup/something"); + } + `); + + expect(collectDependencies(ast).dependencies) + .toEqual(['b/lib/a', 'do', 'setup/something']); + }); + + it('supports template literals as arguments', () => { + const ast = astFromCode('require(`left-pad`)'); + + expect(collectDependencies(ast).dependencies) + .toEqual(['left-pad']); + }); + + it('ignores template literals with interpolations', () => { + const ast = astFromCode('require(`left${"-"}pad`)'); + + expect(collectDependencies(ast).dependencies) + .toEqual([]); + }); + + it('ignores tagged template literals', () => { + const ast = astFromCode('require(tag`left-pad`)'); + + expect(collectDependencies(ast).dependencies) + .toEqual([]); + }); + + it('exposes a string as `dependencyMapName`', () => { + const ast = astFromCode('require("arbitrary")'); + expect(collectDependencies(ast).dependencyMapName) + .toEqual(any(String)); + }); + + it('exposes a string as `dependencyMapName` even without collecting dependencies', () => { + const ast = astFromCode(''); + expect(collectDependencies(ast).dependencyMapName) + .toEqual(any(String)); + }); + + it('replaces all required module ID strings with array lookups and keeps the ID as second argument', () => { + const ast = astFromCode(` + const a = require('b/lib/a'); + const b = require(123); + exports.do = () => require("do"); + if (!something) { + require("setup/something"); + } + `); + + const {dependencyMapName} = collectDependencies(ast); + + expect(codeFromAst(ast)).toEqual(comparableCode(` + const a = require(${dependencyMapName}[0], 'b/lib/a'); + const b = require(123); + exports.do = () => require(${dependencyMapName}[1], "do"); + if (!something) { + require(${dependencyMapName}[2], "setup/something"); + } + `)); + }); +}); + +describe('Dependency collection from optimized ASTs:', () => { + const dependencyMapName = 'arbitrary'; + const {forOptimization} = collectDependencies; + let ast, names; + + beforeEach(() => { + ast = astFromCode(` + const a = require(${dependencyMapName}[0], 'b/lib/a'); + const b = require(123); + exports.do = () => require(${dependencyMapName}[1], "do"); + if (!something) { + require(${dependencyMapName}[2], "setup/something"); + } + `); + names = ['b/lib/a', 'do', 'setup/something']; + }); + + it('passes the `dependencyMapName` through', () => { + const result = forOptimization(ast, names, dependencyMapName); + expect(result.dependencyMapName).toEqual(dependencyMapName); + }); + + it('returns the list of passed in dependencies', () => { + const result = forOptimization(ast, names, dependencyMapName); + expect(result.dependencies).toEqual(names); + }); + + it('only returns dependencies that are in the code', () => { + ast = astFromCode(`require(${dependencyMapName}[1], 'do')`); + const result = forOptimization(ast, names, dependencyMapName); + expect(result.dependencies).toEqual(['do']); + }); + + it('replaces all call signatures inserted by a prior call to `collectDependencies`', () => { + forOptimization(ast, names, dependencyMapName); + expect(codeFromAst(ast)).toEqual(comparableCode(` + const a = require(${dependencyMapName}[0]); + const b = require(123); + exports.do = () => require(${dependencyMapName}[1]); + if (!something) { + require(${dependencyMapName}[2]); + } + `)); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/optimize-module-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/optimize-module-test.js new file mode 100644 index 00000000..8494b014 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/optimize-module-test.js @@ -0,0 +1,97 @@ +/** + * 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'; + +jest.disableAutomock(); + +const optimizeModule = require('../optimize-module'); +const transformModule = require('../transform-module'); +const transform = require('../../../../../transformer.js'); +const {SourceMapConsumer} = require('source-map'); + +const {objectContaining} = jasmine; + +describe('optimizing JS modules', () => { + const filename = 'arbitrary/file.js'; + const optimizationOptions = { + dev: false, + platform: 'android', + }; + const originalCode = + `if (Platform.OS !== 'android') { + require('arbitrary-dev'); + } else { + __DEV__ ? require('arbitrary-android-dev') : require('arbitrary-android-prod'); + }`; + + let transformResult; + beforeAll(done => { + transformModule(originalCode, {filename, transform}, (error, result) => { + if (error) { + throw error; + } + transformResult = JSON.stringify(result); + done(); + }); + }); + + it('copies everything from the transformed file, except for transform results', () => { + const result = optimizeModule(transformResult, optimizationOptions); + const expected = JSON.parse(transformResult); + delete expected.transformed; + expect(result).toEqual(objectContaining(expected)); + }); + + describe('code optimization', () => { + let dependencyMapName, injectedVars, optimized, requireName; + beforeAll(() => { + const result = optimizeModule(transformResult, optimizationOptions); + optimized = result.transformed.default; + injectedVars = optimized.code.match(/function\(([^)]*)/)[1].split(','); + [,requireName,,, dependencyMapName] = injectedVars; + }); + + it('optimizes code', () => { + expect(optimized.code) + .toEqual(`__d(function(${injectedVars}){${requireName}(${dependencyMapName}[0])});`); + }); + + it('extracts dependencies', () => { + expect(optimized.dependencies).toEqual(['arbitrary-android-prod']); + }); + + it('creates source maps', () => { + const consumer = new SourceMapConsumer(optimized.map); + const column = optimized.code.lastIndexOf(requireName + '('); + const loc = findLast(originalCode, 'require'); + + expect(consumer.originalPositionFor({line: 1, column})) + .toEqual(objectContaining(loc)); + }); + + it('does not extract dependencies for polyfills', () => { + const result = optimizeModule( + transformResult, + {...optimizationOptions, isPolyfill: true}, + ); + expect(result.transformed.default.dependencies).toEqual([]); + }); + }); +}); + +function findLast(code, needle) { + const lines = code.split(/(?:(?!.)\s)+/); + let line = lines.length; + while (line--) { + const column = lines[line].lastIndexOf(needle); + if (column !== -1) { + return {line: line + 1, column}; + } + } +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/transform-module-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/transform-module-test.js new file mode 100644 index 00000000..6c912822 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/transform-module-test.js @@ -0,0 +1,232 @@ +/** + * 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'; + +jest.disableAutomock(); + +const transformModule = require('../transform-module'); + +const t = require('babel-types'); +const {SourceMapConsumer} = require('source-map'); +const {fn} = require('../../test-helpers'); +const {parse} = require('babylon'); +const generate = require('babel-generator').default; +const {traverse} = require('babel-core'); + +const {any, objectContaining} = jasmine; + +describe('transforming JS modules:', () => { + const filename = 'arbitrary'; + + let transform; + + beforeEach(() => { + transform = fn(); + transform.stub.yields(null, transformResult()); + }); + + const {bodyAst, sourceCode, transformedCode} = createTestData(); + + const options = variants => ({ + filename, + transform, + variants, + }); + + const transformResult = (body = bodyAst) => ({ + ast: t.file(t.program(body)), + }); + + it('passes through file name and code', done => { + transformModule(sourceCode, options(), (error, result) => { + expect(result).toEqual(objectContaining({ + code: sourceCode, + file: filename, + })); + done(); + }); + }); + + it('exposes a haste ID if present', done => { + const hasteID = 'TheModule'; + const codeWithHasteID = `/** @providesModule ${hasteID} */`; + transformModule(codeWithHasteID, options(), (error, result) => { + expect(result).toEqual(objectContaining({hasteID})); + done(); + }); + }); + + it('sets `type` to `"module"` by default', done => { + transformModule(sourceCode, options(), (error, result) => { + expect(result).toEqual(objectContaining({type: 'module'})); + done(); + }); + }); + + it('sets `type` to `"script"` if the input is a polyfill', done => { + transformModule(sourceCode, {...options(), polyfill: true}, (error, result) => { + expect(result).toEqual(objectContaining({type: 'script'})); + done(); + }); + }); + + it('calls the passed-in transform function with code, file name, and options for all passed in variants', done => { + const variants = {dev: {dev: true}, prod: {dev: false}}; + + transformModule(sourceCode, options(variants), () => { + expect(transform) + .toBeCalledWith({filename, sourceCode, options: variants.dev}, any(Function)); + expect(transform) + .toBeCalledWith({filename, sourceCode, options: variants.prod}, any(Function)); + done(); + }); + }); + + it('calls back with any error yielded by the transform function', done => { + const error = new Error(); + transform.stub.yields(error); + + transformModule(sourceCode, options(), e => { + expect(e).toBe(error); + done(); + }); + }); + + it('wraps the code produced by the transform function into a module factory', done => { + transformModule(sourceCode, options(), (error, result) => { + expect(error).toEqual(null); + + const {code, dependencyMapName} = result.transformed.default; + expect(code.replace(/\s+/g, '')) + .toEqual( + `__d(function(global,require,module,exports,${ + dependencyMapName}){${transformedCode}});` + ); + done(); + }); + }); + + it('wraps the code produced by the transform function into an immediately invoked function expression for polyfills', done => { + transformModule(sourceCode, {...options(), polyfill: true}, (error, result) => { + expect(error).toEqual(null); + + const {code} = result.transformed.default; + expect(code.replace(/\s+/g, '')) + .toEqual(`(function(global){${transformedCode}})(this);`); + done(); + }); + }); + + it('creates source maps', done => { + transformModule(sourceCode, options(), (error, result) => { + const {code, map} = result.transformed.default; + const column = code.indexOf('code'); + const consumer = new SourceMapConsumer(map); + expect(consumer.originalPositionFor({line: 1, column})) + .toEqual(objectContaining({line: 1, column: sourceCode.indexOf('code')})); + done(); + }); + }); + + it('extracts dependencies (require calls)', done => { + const dep1 = 'foo', dep2 = 'bar'; + const code = `require('${dep1}'),require('${dep2}')`; + const {body} = parse(code).program; + transform.stub.yields(null, transformResult(body)); + + transformModule(code, options(), (error, result) => { + expect(result.transformed.default) + .toEqual(objectContaining({dependencies: [dep1, dep2]})); + done(); + }); + }); + + it('transforms for all variants', done => { + const variants = {dev: {dev: true}, prod: {dev: false}}; + transform.stub + .withArgs(filename, sourceCode, variants.dev) + .yields(null, transformResult(bodyAst)) + .withArgs(filename, sourceCode, variants.prod) + .yields(null, transformResult([])); + + transformModule(sourceCode, options(variants), (error, result) => { + const {dev, prod} = result.transformed; + expect(dev.code.replace(/\s+/g, '')) + .toEqual( + `__d(function(global,require,module,exports,${ + dev.dependencyMapName}){arbitrary(code);});` + ); + expect(prod.code.replace(/\s+/g, '')) + .toEqual( + `__d(function(global,require,module,exports,${ + prod.dependencyMapName}){arbitrary(code);});` + ); + done(); + }); + }); + + it('prefixes JSON files with `module.exports = `', done => { + const json = '{"foo":"bar"}'; + + transformModule(json, {...options(), filename: 'some.json'}, (error, result) => { + const {code} = result.transformed.default; + expect(code.replace(/\s+/g, '')) + .toEqual( + '__d(function(global,require,module,exports){' + + `module.exports=${json}});` + ); + done(); + }); + }); + + it('does not create source maps for JSON files', done => { + transformModule('{}', {...options(), filename: 'some.json'}, (error, result) => { + expect(result.transformed.default) + .toEqual(objectContaining({map: null})); + done(); + }); + }); + + it('adds package data for `package.json` files', done => { + const pkg = { + name: 'package-name', + main: 'package/main', + browser: {browser: 'defs'}, + 'react-native': {'react-native': 'defs'}, + }; + + transformModule( + JSON.stringify(pkg), + {...options(), filename: 'arbitrary/package.json'}, + (error, result) => { + expect(result.package).toEqual(pkg); + done(); + }, + ); + }); +}); + +function createTestData() { + // creates test data with an transformed AST, so that we can test source + // map generation. + const sourceCode = 'some(arbitrary(code));'; + const fileAst = parse(sourceCode); + traverse(fileAst, { + CallExpression(path) { + if (path.node.callee.name === 'some') { + path.replaceWith(path.node.arguments[0]); + } + } + }); + return { + bodyAst: fileAst.program.body, + sourceCode, + transformedCode: generate(fileAst).code, + }; +} diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/wrap-worker-fn-test.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/wrap-worker-fn-test.js new file mode 100644 index 00000000..201d3201 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/__tests__/wrap-worker-fn-test.js @@ -0,0 +1,89 @@ +/** + * 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'; + +jest + .disableAutomock() + .setMock('fs', jest.genMockFromModule('fs')) + .mock('mkdirp'); + +const wrapWorkerFn = require('../wrap-worker-fn'); +const {dirname} = require('path'); +const {fn} = require('../../test-helpers'); + +const {any} = jasmine; + +describe('wrapWorkerFn:', () => { + const infile = '/arbitrary/in/file'; + const outfile = '/arbitrary/in/file'; + + let workerFn, wrapped; + beforeEach(() => { + workerFn = fn(); + workerFn.stub.yields(); + wrapped = wrapWorkerFn(workerFn); + }); + + const fs = require('fs'); + const mkdirp = require('mkdirp'); + + it('reads the passed-in file synchronously as UTF-8', done => { + wrapped(infile, outfile, {}, () => { + expect(fs.readFileSync).toBeCalledWith(infile, 'utf8'); + done(); + }); + }); + + it('calls the worker function with file contents and options', done => { + const contents = 'arbitrary(contents);'; + const options = {arbitrary: 'options'}; + fs.readFileSync.mockReturnValue(contents); + wrapped(infile, outfile, options, () => { + expect(workerFn).toBeCalledWith(contents, options, any(Function)); + done(); + }); + }); + + it('passes through any error that the worker function calls back with', done => { + const error = new Error(); + workerFn.stub.yields(error); + wrapped(infile, outfile, {}, e => { + expect(e).toBe(error); + done(); + }); + }); + + it('writes the result to disk', done => { + const result = {arbitrary: 'result'}; + workerFn.stub.yields(null, result); + wrapped(infile, outfile, {}, () => { + expect(mkdirp.sync).toBeCalledWith(dirname(outfile)); + expect(fs.writeFileSync).toBeCalledWith(outfile, JSON.stringify(result), 'utf8'); + done(); + }); + }); + + it('calls back with any error thrown by `mkdirp.sync`', done => { + const error = new Error(); + mkdirp.sync.mockImplementationOnce(() => { throw error; }); + wrapped(infile, outfile, {}, e => { + expect(e).toBe(error); + done(); + }); + }); + + it('calls back with any error thrown by `fs.writeFileSync`', done => { + const error = new Error(); + fs.writeFileSync.mockImplementationOnce(() => { throw error; }); + wrapped(infile, outfile, {}, e => { + expect(e).toBe(error); + done(); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/collect-dependencies.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/collect-dependencies.js new file mode 100644 index 00000000..b17c8c9d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/collect-dependencies.js @@ -0,0 +1,150 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const nullthrows = require('fbjs/lib/nullthrows'); + +const {traverse, types} = require('babel-core'); + +type AST = Object; + +class Replacement { + nameToIndex: Map; + nextIndex: number; + + constructor() { + this.nameToIndex = new Map(); + this.nextIndex = 0; + } + + isRequireCall(callee, firstArg) { + return ( + callee.type === 'Identifier' && callee.name === 'require' && + firstArg && isLiteralString(firstArg) + ); + } + + getIndex(stringLiteralOrTemplateLiteral) { + const name = stringLiteralOrTemplateLiteral.quasis + ? stringLiteralOrTemplateLiteral.quasis[0].value.cooked + : stringLiteralOrTemplateLiteral.value; + let index = this.nameToIndex.get(name); + if (index !== undefined) { + return index; + } + index = this.nextIndex++; + this.nameToIndex.set(name, index); + return index; + } + + getNames() { + return Array.from(this.nameToIndex.keys()); + } + + makeArgs(newId, oldId, dependencyMapIdentifier) { + const mapLookup = createMapLookup(dependencyMapIdentifier, newId); + return [mapLookup, oldId]; + } +} + +class ProdReplacement { + replacement: Replacement; + names: Array; + + constructor(names) { + this.replacement = new Replacement(); + this.names = names; + } + + isRequireCall(callee, firstArg) { + return ( + callee.type === 'Identifier' && + callee.name === 'require' && + firstArg && + firstArg.type === 'MemberExpression' && + firstArg.property && + firstArg.property.type === 'NumericLiteral' + ); + } + + getIndex(memberExpression) { + const id = memberExpression.property.value; + if (id in this.names) { + return this.replacement.getIndex({value: this.names[id]}); + } + + throw new Error( + `${id} is not a known module ID. Existing mappings: ${ + this.names.map((n, i) => `${i} => ${n}`).join(', ')}` + ); + } + + getNames() { + return this.replacement.getNames(); + } + + makeArgs(newId, _, dependencyMapIdentifier) { + const mapLookup = createMapLookup(dependencyMapIdentifier, newId); + return [mapLookup]; + } +} + +function createMapLookup(dependencyMapIdentifier, propertyIdentifier) { + return types.memberExpression( + dependencyMapIdentifier, + propertyIdentifier, + true, + ); +} + +function collectDependencies(ast, replacement, dependencyMapIdentifier) { + const traversalState = {dependencyMapIdentifier}; + traverse(ast, { + Program(path, state) { + if (!state.dependencyMapIdentifier) { + state.dependencyMapIdentifier = + path.scope.generateUidIdentifier('dependencyMap'); + } + }, + CallExpression(path, state) { + const node = path.node; + const arg = node.arguments[0]; + if (replacement.isRequireCall(node.callee, arg)) { + const index = replacement.getIndex(arg); + node.arguments = replacement.makeArgs( + types.numericLiteral(index), + arg, + state.dependencyMapIdentifier, + ); + } + }, + }, null, traversalState); + + return { + dependencies: replacement.getNames(), + dependencyMapName: nullthrows(traversalState.dependencyMapIdentifier).name, + }; +} + +function isLiteralString(node) { + return node.type === 'StringLiteral' || + node.type === 'TemplateLiteral' && node.quasis.length === 1; +} + +exports = module.exports = + (ast: AST) => collectDependencies(ast, new Replacement()); +exports.forOptimization = + (ast: AST, names: Array, dependencyMapName?: string) => + collectDependencies( + ast, + new ProdReplacement(names), + dependencyMapName ? types.identifier(dependencyMapName) : undefined, + ); diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/generate.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/generate.js new file mode 100644 index 00000000..a0f2d755 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/generate.js @@ -0,0 +1,26 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const babelGenerate = require('babel-generator').default; + +function generate(ast: Object, filename: string, sourceCode: string) { + return babelGenerate(ast, { + comments: false, + compact: true, + filename, + sourceFileName: filename, + sourceMaps: true, + sourceMapTarget: filename, + }, sourceCode); +} + +module.exports = generate; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/optimize-module.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/optimize-module.js new file mode 100644 index 00000000..3369f7d1 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/optimize-module.js @@ -0,0 +1,108 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const babel = require('babel-core'); +const collectDependencies = require('./collect-dependencies'); +const constantFolding = require('../../JSTransformer/worker/constant-folding').plugin; +const generate = require('./generate'); +const inline = require('../../JSTransformer/worker/inline').plugin; +const minify = require('../../JSTransformer/worker/minify'); +const sourceMap = require('source-map'); + +import type {TransformedFile, TransformResult} from '../types.flow'; + +export type OptimizationOptions = {| + dev: boolean, + isPolyfill?: boolean, + platform: string, +|}; + +function optimizeModule( + data: string | TransformedFile, + optimizationOptions: OptimizationOptions, +): TransformedFile { + if (typeof data === 'string') { + data = JSON.parse(data); + } + const {code, file, transformed} = data; + const result = {...data, transformed: {}}; + + //$FlowIssue #14545724 + Object.entries(transformed).forEach(([k, t: TransformResult]: [*, TransformResult]) => { + result.transformed[k] = optimize(t, file, code, optimizationOptions); + }); + + return result; +} + +function optimize(transformed, file, originalCode, options): TransformResult { + const {code, dependencyMapName, map} = transformed; + const optimized = optimizeCode(code, map, file, options); + + let dependencies; + if (options.isPolyfill) { + dependencies = []; + } else { + ({dependencies} = collectDependencies.forOptimization( + optimized.ast, + transformed.dependencies, + dependencyMapName, + )); + } + + const inputMap = transformed.map; + const gen = generate(optimized.ast, file, originalCode); + + const min = minify( + file, + gen.code, + inputMap && mergeSourceMaps(file, inputMap, gen.map), + ); + return {code: min.code, map: inputMap && min.map, dependencies}; +} + +function optimizeCode(code, map, filename, inliningOptions) { + return babel.transform(code, { + plugins: [ + [constantFolding], + [inline, {...inliningOptions, isWrapped: true}], + ], + babelrc: false, + code: false, + filename, + }); +} + +function mergeSourceMaps(file, originalMap, secondMap) { + const merged = new sourceMap.SourceMapGenerator(); + const inputMap = new sourceMap.SourceMapConsumer(originalMap); + new sourceMap.SourceMapConsumer(secondMap) + .eachMapping(mapping => { + const original = inputMap.originalPositionFor({ + line: mapping.originalLine, + column: mapping.originalColumn, + }); + if (original.line == null) { + return; + } + + merged.addMapping({ + generated: {line: mapping.generatedLine, column: mapping.generatedColumn}, + original: {line: original.line, column: original.column || 0}, + source: file, + name: original.name || mapping.name, + }); + }); + return merged.toJSON(); +} + +module.exports = optimizeModule; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/transform-module.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/transform-module.js new file mode 100644 index 00000000..bc079b0f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/transform-module.js @@ -0,0 +1,165 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const babel = require('babel-core'); +const collectDependencies = require('./collect-dependencies'); +const docblock = require('../../node-haste/DependencyGraph/docblock'); +const generate = require('./generate'); +const series = require('async/series'); + +const {basename} = require('path'); + +import type { + Callback, + TransformedFile, + TransformFn, + TransformFnResult, + TransformResult, + TransformVariants, +} from '../types.flow'; + +export type TransformOptions = {| + filename: string, + polyfill?: boolean, + transform: TransformFn, + variants?: TransformVariants, +|}; + +const defaultVariants = {default: {}}; +const moduleFactoryParameters = ['global', 'require', 'module', 'exports']; +const polyfillFactoryParameters = ['global']; + +function transformModule( + code: string, + options: TransformOptions, + callback: Callback, +): void { + if (options.filename.endsWith('.json')) { + return transformJSON(code, options, callback); + } + + const {filename, transform, variants = defaultVariants} = options; + const tasks = {}; + Object.keys(variants).forEach(name => { + tasks[name] = cb => transform({ + filename, + sourceCode: code, + options: variants[name], + }, cb); + }); + + series(tasks, (error, results: {[key: string]: TransformFnResult}) => { + if (error) { + callback(error); + return; + } + + const transformed: {[key: string]: TransformResult} = {}; + + //$FlowIssue #14545724 + Object.entries(results).forEach(([key, value]: [*, TransformFnResult]) => { + transformed[key] = makeResult(value.ast, filename, code, options.polyfill); + }); + + const annotations = docblock.parseAsObject(docblock.extract(code)); + + callback(null, { + code, + file: filename, + hasteID: annotations.providesModule || annotations.provide || null, + transformed, + type: options.polyfill ? 'script' : 'module', + }); + }); +} + +function transformJSON(json, options, callback) { + const value = JSON.parse(json); + const {filename} = options; + const code = + `__d(function(${moduleFactoryParameters.join(', ')}) { module.exports = \n${ + json + }\n});`; + + const moduleData = { + code, + map: null, // no source map for JSON files! + dependencies: [], + }; + const transformed = {}; + + Object + .keys(options.variants || defaultVariants) + .forEach(key => (transformed[key] = moduleData)); + + const result: TransformedFile = { + code: json, + file: filename, + hasteID: value.name, + transformed, + type: 'module', + }; + + if (basename(filename) === 'package.json') { + result.package = { + name: value.name, + main: value.main, + browser: value.browser, + 'react-native': value['react-native'], + }; + } + callback(null, result); +} + +function makeResult(ast, filename, sourceCode, isPolyfill = false) { + let dependencies, dependencyMapName, file; + if (isPolyfill) { + dependencies = []; + file = wrapPolyfill(ast); + } else { + ({dependencies, dependencyMapName} = collectDependencies(ast)); + file = wrapModule(ast, dependencyMapName); + } + + const gen = generate(file, filename, sourceCode); + return {code: gen.code, map: gen.map, dependencies, dependencyMapName}; +} + +function wrapModule(file, dependencyMapName) { + const t = babel.types; + const params = moduleFactoryParameters.concat(dependencyMapName); + const factory = functionFromProgram(file.program, params); + const def = t.callExpression(t.identifier('__d'), [factory]); + return t.file(t.program([t.expressionStatement(def)])); +} + +function wrapPolyfill(file) { + const t = babel.types; + const factory = functionFromProgram(file.program, polyfillFactoryParameters); + const iife = t.callExpression(factory, [t.identifier('this')]); + return t.file(t.program([t.expressionStatement(iife)])); +} + +function functionFromProgram(program, parameters) { + const t = babel.types; + return t.functionExpression( + t.identifier(''), + parameters.map(makeIdentifier), + t.blockStatement(program.body, program.directives), + ); +} + +function makeIdentifier(name) { + return babel.types.identifier(name); +} + +module.exports = transformModule; diff --git a/packages/metro-bundler/react-packager/src/ModuleGraph/worker/wrap-worker-fn.js b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/wrap-worker-fn.js new file mode 100644 index 00000000..83bc16a2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/ModuleGraph/worker/wrap-worker-fn.js @@ -0,0 +1,62 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const fs = require('fs'); +const mkdirp = require('mkdirp'); + +const {dirname} = require('path'); + +import type {Callback} from '../types.flow'; + +type Path = string; +type WorkerFn = ( + fileContents: string, + options: Options, + callback: Callback, +) => void; +export type WorkerFnWithIO = ( + infile: Path, + outfile: Path, + options: Options, + callback: Callback<>, +) => void; + +function wrapWorkerFn( + workerFunction: WorkerFn, +): WorkerFnWithIO { + return ( + infile: Path, + outfile: Path, + options: Options, + callback: Callback<>, + ) => { + const contents = fs.readFileSync(infile, 'utf8'); + workerFunction(contents, options, (error, result) => { + if (error) { + callback(error); + return; + } + + try { + mkdirp.sync(dirname(outfile)); + fs.writeFileSync(outfile, JSON.stringify(result), 'utf8'); + } catch (writeError) { + callback(writeError); + return; + } + + callback(null); + }); + }; +} + +module.exports = wrapWorkerFn; diff --git a/packages/metro-bundler/react-packager/src/Resolver/__tests__/Resolver-test.js b/packages/metro-bundler/react-packager/src/Resolver/__tests__/Resolver-test.js new file mode 100644 index 00000000..684ef403 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/__tests__/Resolver-test.js @@ -0,0 +1,548 @@ +/** + * 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.unmock('../'); +jest.unmock('../../../../defaults'); +jest.mock('path'); + +const {join: pathJoin} = require.requireActual('path'); +const DependencyGraph = jest.fn(); +jest.setMock('../../node-haste', DependencyGraph); +let Module; +let Polyfill; + +describe('Resolver', function() { + let Resolver, path; + + beforeEach(function() { + Resolver = require('../'); + path = require('path'); + DependencyGraph.mockClear(); + Module = jest.fn(function() { + this.getName = jest.fn(); + this.getDependencies = jest.fn(); + this.isPolyfill = jest.fn().mockReturnValue(false); + this.isJSON = jest.fn().mockReturnValue(false); + }); + Polyfill = jest.fn(function() { + var polyfill = new Module(); + polyfill.isPolyfill.mockReturnValue(true); + return polyfill; + }); + + DependencyGraph.replacePatterns = require.requireActual('../../node-haste/lib/replacePatterns'); + DependencyGraph.prototype.createPolyfill = jest.fn(); + DependencyGraph.prototype.getDependencies = jest.fn(); + + // For the polyfillDeps + path.join = jest.fn((a, b) => b); + + DependencyGraph.prototype.load = jest.fn(() => Promise.resolve()); + }); + + class ResolutionResponseMock { + constructor({dependencies, mainModuleId}) { + this.dependencies = dependencies; + this.mainModuleId = mainModuleId; + this.getModuleId = createGetModuleId(); + } + + prependDependency(dependency) { + this.dependencies.unshift(dependency); + } + + finalize() { + return Promise.resolve(this); + } + + getResolvedDependencyPairs() { + return []; + } + } + + function createModule(id, dependencies) { + var module = new Module({}); + module.path = id; + module.getName.mockImplementation(() => Promise.resolve(id)); + module.getDependencies.mockImplementation(() => Promise.resolve(dependencies)); + return module; + } + + function createJsonModule(id) { + const module = createModule(id, []); + module.isJSON.mockReturnValue(true); + return module; + } + + function createPolyfill(id, dependencies) { + var polyfill = new Polyfill({}); + polyfill.getName = jest.fn(() => Promise.resolve(id)); + polyfill.getDependencies = + jest.fn(() => Promise.resolve(dependencies)); + return polyfill; + } + + describe('getDependencies', function() { + it('forwards transform options to the dependency graph', function() { + const transformOptions = {arbitrary: 'options'}; + const platform = 'ios'; + const entry = '/root/index.js'; + + DependencyGraph.prototype.getDependencies.mockImplementation( + () => Promise.reject()); + new Resolver({projectRoot: '/root'}) + .getDependencies(entry, {platform}, transformOptions); + expect(DependencyGraph.prototype.getDependencies).toBeCalledWith({ + entryPath: entry, + platform: platform, + transformOptions: transformOptions, + recursive: true, + }); + }); + + it('passes custom platforms to the dependency graph', function() { + new Resolver({ // eslint-disable-line no-new + projectRoot: '/root', + platforms: ['ios', 'windows', 'vr'], + }); + const platforms = DependencyGraph.mock.calls[0][0].platforms; + expect(platforms).toEqual(['ios', 'windows', 'vr']); + }); + + it('should get dependencies with polyfills', function() { + var module = createModule('index'); + var deps = [module]; + + var depResolver = new Resolver({ + projectRoot: '/root', + }); + + DependencyGraph.prototype.getDependencies.mockImplementation(function() { + return Promise.resolve(new ResolutionResponseMock({ + dependencies: deps, + mainModuleId: 'index', + })); + }); + + return depResolver + .getDependencies( + '/root/index.js', + { dev: false }, + undefined, + undefined, + createGetModuleId() + ).then(function(result) { + expect(result.mainModuleId).toEqual('index'); + expect(result.dependencies[result.dependencies.length - 1]).toBe(module); + expect( + DependencyGraph + .prototype + .createPolyfill + .mock + .calls + .map((call) => call[0])) + .toEqual([ + { id: 'polyfills/polyfills.js', + file: 'polyfills/polyfills.js', + dependencies: [] + }, + { id: 'polyfills/console.js', + file: 'polyfills/console.js', + dependencies: [ + 'polyfills/polyfills.js' + ], + }, + { id: 'polyfills/error-guard.js', + file: 'polyfills/error-guard.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js' + ], + }, + { id: 'polyfills/Number.es6.js', + file: 'polyfills/Number.es6.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js' + ], + }, + { id: 'polyfills/String.prototype.es6.js', + file: 'polyfills/String.prototype.es6.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + ], + }, + { id: 'polyfills/Array.prototype.es6.js', + file: 'polyfills/Array.prototype.es6.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + 'polyfills/String.prototype.es6.js', + ], + }, + { id: 'polyfills/Array.es6.js', + file: 'polyfills/Array.es6.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + 'polyfills/String.prototype.es6.js', + 'polyfills/Array.prototype.es6.js', + ], + }, + { id: 'polyfills/Object.es7.js', + file: 'polyfills/Object.es7.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + 'polyfills/String.prototype.es6.js', + 'polyfills/Array.prototype.es6.js', + 'polyfills/Array.es6.js', + ], + }, + { id: 'polyfills/babelHelpers.js', + file: 'polyfills/babelHelpers.js', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + 'polyfills/String.prototype.es6.js', + 'polyfills/Array.prototype.es6.js', + 'polyfills/Array.es6.js', + 'polyfills/Object.es7.js', + ], + }, + ].map(({id, file, dependencies}) => ({ + id: pathJoin(__dirname, '..', id), + file: pathJoin(__dirname, '..', file), + dependencies: dependencies.map((d => pathJoin(__dirname, '..', d))), + }))); + }); + }); + + it('should get dependencies with polyfills', function() { + var module = createModule('index'); + var deps = [module]; + + var depResolver = new Resolver({ + projectRoot: '/root', + }); + + DependencyGraph.prototype.getDependencies.mockImplementation(function() { + return Promise.resolve(new ResolutionResponseMock({ + dependencies: deps, + mainModuleId: 'index', + })); + }); + + const polyfill = {}; + DependencyGraph.prototype.createPolyfill.mockReturnValueOnce(polyfill); + return depResolver + .getDependencies( + '/root/index.js', + { dev: true }, + undefined, + undefined, + createGetModuleId() + ).then(function(result) { + expect(result.mainModuleId).toEqual('index'); + expect(DependencyGraph.mock.instances[0].getDependencies) + .toBeCalledWith({entryPath: '/root/index.js', recursive: true}); + expect(result.dependencies[0]).toBe(polyfill); + expect(result.dependencies[result.dependencies.length - 1]) + .toBe(module); + }); + }); + + it('should pass in more polyfills', function() { + var module = createModule('index'); + var deps = [module]; + + var depResolver = new Resolver({ + projectRoot: '/root', + polyfillModuleNames: ['some module'], + }); + + DependencyGraph.prototype.getDependencies.mockImplementation(function() { + return Promise.resolve(new ResolutionResponseMock({ + dependencies: deps, + mainModuleId: 'index', + })); + }); + + return depResolver + .getDependencies( + '/root/index.js', + { dev: false }, + undefined, + undefined, + createGetModuleId() + ).then((result) => { + expect(result.mainModuleId).toEqual('index'); + expect(DependencyGraph.prototype.createPolyfill.mock.calls[result.dependencies.length - 2]).toEqual([ + { file: 'some module', + id: 'some module', + dependencies: [ + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + 'polyfills/Number.es6.js', + 'polyfills/String.prototype.es6.js', + 'polyfills/Array.prototype.es6.js', + 'polyfills/Array.es6.js', + 'polyfills/Object.es7.js', + 'polyfills/babelHelpers.js', + ].map(d => pathJoin(__dirname, '..', d)) + }, + ]); + }); + }); + }); + + describe('wrapModule', function() { + let depResolver; + beforeEach(() => { + depResolver = new Resolver({ + depResolver, + projectRoot: '/root', + }); + }); + + it('should resolve modules', function() { + /*eslint-disable */ + var code = [ + // require + 'require("x")', + 'require("y");require(\'abc\');', + 'require( \'z\' )', + 'require( "a")', + 'require("b" )', + ].join('\n'); + /*eslint-disable */ + + function *findDependencyOffsets() { + const re = /(['"']).*?\1/g; + let match; + while ((match = re.exec(code))) { + yield match.index; + } + } + + const dependencyOffsets = Array.from(findDependencyOffsets()); + const module = createModule('test module', ['x', 'y']); + const resolutionResponse = new ResolutionResponseMock({ + dependencies: [module], + mainModuleId: 'test module', + }); + + resolutionResponse.getResolvedDependencyPairs = (module) => { + return [ + ['x', createModule('changed')], + ['y', createModule('Y')], + ['abc', createModule('abc')] + ]; + } + + const moduleIds = new Map( + resolutionResponse + .getResolvedDependencyPairs() + .map(([importId, module]) => [ + importId, + padRight(resolutionResponse.getModuleId(module), importId.length + 2), + ]) + ); + + return depResolver.wrapModule({ + resolutionResponse, + module: module, + name: 'test module', + code, + meta: {dependencyOffsets}, + dev: false, + }).then(({code: processedCode}) => { + expect(processedCode).toEqual([ + '__d(/* test module */function(global, require, module, exports) {' + + // require + `require(${moduleIds.get('x')}) // ${moduleIds.get('x').trim()} = x`, + `require(${moduleIds.get('y')});require(${moduleIds.get('abc') + }); // ${moduleIds.get('abc').trim()} = abc // ${moduleIds.get('y').trim()} = y`, + 'require( \'z\' )', + 'require( "a")', + 'require("b" )', + `}, ${resolutionResponse.getModuleId(module)});`, + ].join('\n')); + }); + }); + + it('should add module transport names as fourth argument to `__d`', () => { + const module = createModule('test module'); + const code = 'arbitrary(code)' + const resolutionResponse = new ResolutionResponseMock({ + dependencies: [module], + mainModuleId: 'test module', + }); + return depResolver.wrapModule({ + resolutionResponse, + code, + module, + name: 'test module', + dev: true, + }).then(({code: processedCode}) => + expect(processedCode).toEqual([ + '__d(/* test module */function(global, require, module, exports) {' + + code, + `}, ${resolutionResponse.getModuleId(module)}, null, "test module");` + ].join('\n')) + ); + }); + + it('should pass through passed-in source maps', () => { + const module = createModule('test module'); + const resolutionResponse = new ResolutionResponseMock({ + dependencies: [module], + mainModuleId: 'test module', + }); + const inputMap = {version: 3, mappings: 'ARBITRARY'}; + return depResolver.wrapModule({ + resolutionResponse, + module, + name: 'test module', + code: 'arbitrary(code)', + map: inputMap, + }).then(({map}) => expect(map).toBe(inputMap)); + }); + + it('should resolve polyfills', function () { + const depResolver = new Resolver({ + projectRoot: '/root', + }); + const polyfill = createPolyfill('test polyfill', []); + const code = [ + 'global.fetch = () => 1;', + ].join(''); + return depResolver.wrapModule({ + module: polyfill, + code + }).then(({code: processedCode}) => { + expect(processedCode).toEqual([ + '(function(global) {', + 'global.fetch = () => 1;', + "\n})(typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : this);", + ].join('')); + }); + }); + + describe('JSON files:', () => { + const code = JSON.stringify({arbitrary: "data"}); + const id = 'arbitrary.json'; + let depResolver, module, resolutionResponse; + + beforeEach(() => { + depResolver = new Resolver({projectRoot: '/root'}); + module = createJsonModule(id); + resolutionResponse = new ResolutionResponseMock({ + dependencies: [module], + mainModuleId: id, + }); + }); + + it('should prefix JSON files with `module.exports=`', () => { + return depResolver + .wrapModule({resolutionResponse, module, name: id, code, dev: false}) + .then(({code: processedCode}) => + expect(processedCode).toEqual([ + `__d(/* ${id} */function(global, require, module, exports) {`, + `module.exports = ${code}\n}, ${resolutionResponse.getModuleId(module)});`, + ].join(''))); + }); + }); + + describe('minification:', () => { + const code ='arbitrary(code)'; + const id = 'arbitrary.js'; + let depResolver, minifyCode, module, resolutionResponse, sourceMap; + + beforeEach(() => { + minifyCode = jest.fn((filename, code, map) => + Promise.resolve({code, map})); + depResolver = new Resolver({ + projectRoot: '/root', + minifyCode, + }); + module = createModule(id); + module.path = '/arbitrary/path.js'; + resolutionResponse = new ResolutionResponseMock({ + dependencies: [module], + mainModuleId: id, + }); + sourceMap = {version: 3, sources: ['input'], mappings: 'whatever'}; + }); + + it('should invoke the minifier with the wrapped code', () => { + const wrappedCode = + `__d(/* ${id} */function(global, require, module, exports) {${ + code}\n}, ${resolutionResponse.getModuleId(module)});` + return depResolver + .wrapModule({ + resolutionResponse, + module, + name: id, + code, + map: sourceMap, + minify: true, + dev: false, + }).then(() => { + expect(minifyCode).toBeCalledWith(module.path, wrappedCode, sourceMap); + }); + }); + + it('should use minified code', () => { + const minifiedCode = 'minified(code)'; + const minifiedMap = {version: 3, file: ['minified']}; + minifyCode.mockReturnValue(Promise.resolve({code: minifiedCode, map: minifiedMap})); + return depResolver + .wrapModule({resolutionResponse, module, name: id, code, minify: true}) + .then(({code, map}) => { + expect(code).toEqual(minifiedCode); + expect(map).toEqual(minifiedMap); + }); + }); + }); + }); + + function createGetModuleId() { + let nextId = 1; + const knownIds = new Map(); + function createId(path) { + const id = nextId; + nextId += 1; + knownIds.set(path, id); + return id; + } + + return ({path}) => knownIds.get(path) || createId(path); + } + + function padRight(value, width) { + const s = String(value); + const diff = width - s.length; + return diff > 0 ? s + Array(diff + 1).join(' ') : s; + } +}); diff --git a/packages/metro-bundler/react-packager/src/Resolver/index.js b/packages/metro-bundler/react-packager/src/Resolver/index.js new file mode 100644 index 00000000..e5f5a839 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/index.js @@ -0,0 +1,289 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const DependencyGraph = require('../node-haste'); + +const defaults = require('../../../defaults'); +const pathJoin = require('path').join; + +import type ResolutionResponse from '../node-haste/DependencyGraph/ResolutionResponse'; +import type Module from '../node-haste/Module'; +import type {SourceMap} from '../lib/SourceMap'; +import type {Options as TransformOptions} from '../JSTransformer/worker/worker'; +import type {Reporter} from '../lib/reporting'; +import type {TransformCode} from '../node-haste/Module'; +import type Cache from '../node-haste/Cache'; +import type GlobalTransformCache from '../lib/GlobalTransformCache'; + +type MinifyCode = (filePath: string, code: string, map: SourceMap) => + Promise<{code: string, map: SourceMap}>; + +type Options = { + assetExts: Array, + blacklistRE?: RegExp, + cache: Cache, + extraNodeModules?: {}, + globalTransformCache: ?GlobalTransformCache, + minifyCode: MinifyCode, + platforms: Array, + polyfillModuleNames?: Array, + projectRoots: Array, + providesModuleNodeModules?: Array, + reporter: Reporter, + resetCache: boolean, + transformCacheKey: string, + transformCode: TransformCode, + watch?: boolean, +}; + +class Resolver { + + _depGraph: DependencyGraph; + _minifyCode: MinifyCode; + _polyfillModuleNames: Array; + + constructor(opts: Options) { + this._depGraph = new DependencyGraph({ + assetDependencies: ['react-native/Libraries/Image/AssetRegistry'], + assetExts: opts.assetExts, + cache: opts.cache, + extraNodeModules: opts.extraNodeModules, + globalTransformCache: opts.globalTransformCache, + ignoreFilePath: function(filepath) { + return filepath.indexOf('__tests__') !== -1 || + (opts.blacklistRE != null && opts.blacklistRE.test(filepath)); + }, + moduleOptions: { + cacheTransformResults: true, + resetCache: opts.resetCache, + }, + platforms: opts.platforms, + preferNativePlatform: true, + providesModuleNodeModules: opts.providesModuleNodeModules || defaults.providesModuleNodeModules, + reporter: opts.reporter, + resetCache: opts.resetCache, + roots: opts.projectRoots, + transformCacheKey: opts.transformCacheKey, + transformCode: opts.transformCode, + watch: opts.watch || false, + }); + + this._minifyCode = opts.minifyCode; + this._polyfillModuleNames = opts.polyfillModuleNames || []; + + this._depGraph.load().catch(err => { + console.error(err.message + '\n' + err.stack); + process.exit(1); + }); + } + + getShallowDependencies( + entryFile: string, + transformOptions: TransformOptions, + ): Array { + return this._depGraph.getShallowDependencies(entryFile, transformOptions); + } + + getModuleForPath(entryFile: string): Module { + return this._depGraph.getModuleForPath(entryFile); + } + + getDependencies( + entryPath: string, + options: {platform: string, recursive?: boolean}, + transformOptions: TransformOptions, + onProgress?: ?(finishedModules: number, totalModules: number) => mixed, + getModuleId: mixed, + ): Promise { + const {platform, recursive = true} = options; + return this._depGraph.getDependencies({ + entryPath, + platform, + transformOptions, + recursive, + onProgress, + }).then(resolutionResponse => { + this._getPolyfillDependencies().reverse().forEach( + polyfill => resolutionResponse.prependDependency(polyfill) + ); + + resolutionResponse.getModuleId = getModuleId; + return resolutionResponse.finalize(); + }); + } + + getModuleSystemDependencies({dev = true}: {dev?: boolean}): Array { + + const prelude = dev + ? pathJoin(__dirname, 'polyfills/prelude_dev.js') + : pathJoin(__dirname, 'polyfills/prelude.js'); + + const moduleSystem = defaults.moduleSystem; + + return [ + prelude, + moduleSystem, + ].map(moduleName => this._depGraph.createPolyfill({ + file: moduleName, + id: moduleName, + dependencies: [], + })); + } + + _getPolyfillDependencies(): Array { + const polyfillModuleNames = defaults.polyfills.concat(this._polyfillModuleNames); + + return polyfillModuleNames.map( + (polyfillModuleName, idx) => this._depGraph.createPolyfill({ + file: polyfillModuleName, + id: polyfillModuleName, + dependencies: polyfillModuleNames.slice(0, idx), + }) + ); + } + + resolveRequires( + resolutionResponse: ResolutionResponse, + module: Module, + code: string, + dependencyOffsets: Array = [], + ): string { + const resolvedDeps = Object.create(null); + + // here, we build a map of all require strings (relative and absolute) + // to the canonical ID of the module they reference + resolutionResponse.getResolvedDependencyPairs(module) + .forEach(([depName, depModule]) => { + if (depModule) { + /* $FlowFixMe: `getModuleId` is monkey-patched so may not exist */ + resolvedDeps[depName] = resolutionResponse.getModuleId(depModule); + } + }); + + // if we have a canonical ID for the module imported here, + // we use it, so that require() is always called with the same + // id for every module. + // Example: + // -- in a/b.js: + // require('./c') => require(3); + // -- in b/index.js: + // require('../a/c') => require(3); + return dependencyOffsets.reduceRight( + ([unhandled, handled], offset) => [ + unhandled.slice(0, offset), + replaceDependencyID(unhandled.slice(offset) + handled, resolvedDeps), + ], + [code, ''], + ).join(''); + } + + wrapModule({ + resolutionResponse, + module, + name, + map, + code, + meta = {}, + dev = true, + minify = false, + }: { + resolutionResponse: ResolutionResponse, + module: Module, + name: string, + map: SourceMap, + code: string, + meta?: { + dependencyOffsets?: Array, + }, + dev?: boolean, + minify?: boolean, + }) { + if (module.isJSON()) { + code = `module.exports = ${code}`; + } + + if (module.isPolyfill()) { + code = definePolyfillCode(code); + } else { + /* $FlowFixMe: `getModuleId` is monkey-patched so may not exist */ + const moduleId = resolutionResponse.getModuleId(module); + code = this.resolveRequires( + resolutionResponse, + module, + code, + meta.dependencyOffsets + ); + code = defineModuleCode(moduleId, code, name, dev); + } + + return minify + ? this._minifyCode(module.path, code, map) + : Promise.resolve({code, map}); + } + + minifyModule( + {path, code, map}: {path: string, code: string, map: SourceMap}, + ): Promise<{code: string, map: SourceMap}> { + return this._minifyCode(path, code, map); + } + + getDependencyGraph(): DependencyGraph { + return this._depGraph; + } +} + +function defineModuleCode(moduleName, code, verboseName = '', dev = true) { + return [ + `__d(/* ${verboseName} */`, + 'function(global, require, module, exports) {', // module factory + code, + '\n}, ', + `${JSON.stringify(moduleName)}`, // module id, null = id map. used in ModuleGraph + dev ? `, null, ${JSON.stringify(verboseName)}` : '', + ');', + ].join(''); +} + +function definePolyfillCode(code,) { + return [ + '(function(global) {', + code, + `\n})(typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : this);`, + ].join(''); +} + +const reDepencencyString = /^(['"])([^'"']*)\1/; +function replaceDependencyID(stringWithDependencyIDAtStart, resolvedDeps) { + const match = reDepencencyString.exec(stringWithDependencyIDAtStart); + const dependencyName = match && match[2]; + if (match != null && dependencyName in resolvedDeps) { + const {length} = match[0]; + const id = String(resolvedDeps[dependencyName]); + return ( + padRight(id, length) + + stringWithDependencyIDAtStart + .slice(length) + .replace(/$/m, ` // ${id} = ${dependencyName}`) + ); + } else { + return stringWithDependencyIDAtStart; + } +} + +function padRight(string, length) { + return string.length < length + ? string + Array(length - string.length + 1).join(' ') + : string; +} + +module.exports = Resolver; diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.es6.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.es6.js new file mode 100644 index 00000000..8f9088fa --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.es6.js @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2013-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. + * + * @provides Array.es6 + * @polyfill + */ + +/* eslint-disable */ + +/** + * Creates an array from array like objects. + * + * https://people.mozilla.org/~jorendorff/es6-draft.html#sec-array.from + */ +if (!Array.from) { + Array.from = function(arrayLike /*, mapFn, thisArg */) { + if (arrayLike == null) { + throw new TypeError('Object is null or undefined'); + } + + // Optional args. + var mapFn = arguments[1]; + var thisArg = arguments[2]; + + var C = this; + var items = Object(arrayLike); + var symbolIterator = typeof Symbol === 'function' + ? Symbol.iterator + : '@@iterator'; + var mapping = typeof mapFn === 'function'; + var usingIterator = typeof items[symbolIterator] === 'function'; + var key = 0; + var ret; + var value; + + if (usingIterator) { + ret = typeof C === 'function' + ? new C() + : []; + var it = items[symbolIterator](); + var next; + + while (!(next = it.next()).done) { + value = next.value; + + if (mapping) { + value = mapFn.call(thisArg, value, key); + } + + ret[key] = value; + key += 1; + } + + ret.length = key; + return ret; + } + + var len = items.length; + if (isNaN(len) || len < 0) { + len = 0; + } + + ret = typeof C === 'function' + ? new C(len) + : new Array(len); + + while (key < len) { + value = items[key]; + + if (mapping) { + value = mapFn.call(thisArg, value, key); + } + + ret[key] = value; + + key += 1; + } + + ret.length = key; + return ret; + }; +} diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.prototype.es6.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.prototype.es6.js new file mode 100644 index 00000000..767eec74 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Array.prototype.es6.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. + * + * @provides Array.prototype.es6 + * @polyfill + */ + +/* eslint-disable */ + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex +function findIndex(predicate, context) { + if (this == null) { + throw new TypeError( + 'Array.prototype.findIndex called on null or undefined' + ); + } + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + var list = Object(this); + var length = list.length >>> 0; + for (var i = 0; i < length; i++) { + if (predicate.call(context, list[i], i, list)) { + return i; + } + } + return -1; +} + +if (!Array.prototype.findIndex) { + Object.defineProperty(Array.prototype, 'findIndex', { + enumerable: false, + writable: true, + configurable: true, + value: findIndex + }); +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find +if (!Array.prototype.find) { + Object.defineProperty(Array.prototype, 'find', { + enumerable: false, + writable: true, + configurable: true, + value: function(predicate, context) { + if (this == null) { + throw new TypeError( + 'Array.prototype.find called on null or undefined' + ); + } + var index = findIndex.call(this, predicate, context); + return index === -1 ? undefined : this[index]; + } + }); +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes +if (!Array.prototype.includes) { + Object.defineProperty(Array.prototype, 'includes', { + enumerable: false, + writable: true, + configurable: true, + value: function (searchElement) { + var O = Object(this); + var len = parseInt(O.length) || 0; + if (len === 0) { + return false; + } + var n = parseInt(arguments[1]) || 0; + var k; + if (n >= 0) { + k = n; + } else { + k = len + n; + if (k < 0) { + k = 0; + } + } + var currentElement; + while (k < len) { + currentElement = O[k]; + if (searchElement === currentElement || + (searchElement !== searchElement && currentElement !== currentElement)) { + return true; + } + k++; + } + return false; + } + }); +} diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/Number.es6.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Number.es6.js new file mode 100644 index 00000000..6f077316 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Number.es6.js @@ -0,0 +1,41 @@ +/** + * 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. + * + * @provides Number.es6 + * @polyfill + */ + +/* eslint-disable strict */ + +if (Number.EPSILON === undefined) { + Object.defineProperty(Number, 'EPSILON', { + value: Math.pow(2, -52), + }); +} +if (Number.MAX_SAFE_INTEGER === undefined) { + Object.defineProperty(Number, 'MAX_SAFE_INTEGER', { + value: Math.pow(2, 53) - 1, + }); +} +if (Number.MIN_SAFE_INTEGER === undefined) { + Object.defineProperty(Number, 'MIN_SAFE_INTEGER', { + value: -(Math.pow(2, 53) - 1), + }); +} +if (!Number.isNaN) { + // https://github.com/dherman/tc39-codex-wiki/blob/master/data/es6/number/index.md#polyfill-for-numberisnan + const globalIsNaN = global.isNaN; + Object.defineProperty(Number, 'isNaN', { + configurable: true, + enumerable: false, + value: function isNaN(value) { + return typeof value === 'number' && globalIsNaN(value); + }, + writable: true, + }); +} diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/Object.es7.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Object.es7.js new file mode 100644 index 00000000..1516fc20 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/Object.es7.js @@ -0,0 +1,60 @@ +/** + * 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. + * + * @provides Object.es7 + * @polyfill + */ + +(function() { + 'use strict'; + + const hasOwnProperty = Object.prototype.hasOwnProperty; + + /** + * Returns an array of the given object's own enumerable entries. + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries + */ + if (typeof Object.entries !== 'function') { + Object.entries = function(object) { + // `null` and `undefined` values are not allowed. + if (object == null) { + throw new TypeError('Object.entries called on non-object'); + } + + const entries = []; + for (const key in object) { + if (hasOwnProperty.call(object, key)) { + entries.push([key, object[key]]); + } + } + return entries; + }; + } + + /** + * Returns an array of the given object's own enumerable entries. + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/values + */ + if (typeof Object.values !== 'function') { + Object.values = function(object) { + // `null` and `undefined` values are not allowed. + if (object == null) { + throw new TypeError('Object.values called on non-object'); + } + + const values = []; + for (const key in object) { + if (hasOwnProperty.call(object, key)) { + values.push(object[key]); + } + } + return values; + }; + } + +})(); diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/String.prototype.es6.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/String.prototype.es6.js new file mode 100644 index 00000000..6bfc421f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/String.prototype.es6.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2013-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. + * + * @provides String.prototype.es6 + * @polyfill + */ + +/* eslint-disable strict, no-extend-native, no-bitwise */ + +/* + * NOTE: We use (Number(x) || 0) to replace NaN values with zero. + */ + +if (!String.prototype.startsWith) { + String.prototype.startsWith = function(search) { + 'use strict'; + if (this == null) { + throw TypeError(); + } + var string = String(this); + var pos = arguments.length > 1 ? + (Number(arguments[1]) || 0) : 0; + var start = Math.min(Math.max(pos, 0), string.length); + return string.indexOf(String(search), pos) === start; + }; +} + +if (!String.prototype.endsWith) { + String.prototype.endsWith = function(search) { + 'use strict'; + if (this == null) { + throw TypeError(); + } + var string = String(this); + var stringLength = string.length; + var searchString = String(search); + var pos = arguments.length > 1 ? + (Number(arguments[1]) || 0) : stringLength; + var end = Math.min(Math.max(pos, 0), stringLength); + var start = end - searchString.length; + if (start < 0) { + return false; + } + return string.lastIndexOf(searchString, start) === start; + }; +} + +if (!String.prototype.repeat) { + String.prototype.repeat = function(count) { + 'use strict'; + if (this == null) { + throw TypeError(); + } + var string = String(this); + count = Number(count) || 0; + if (count < 0 || count === Infinity) { + throw RangeError(); + } + if (count === 1) { + return string; + } + var result = ''; + while (count) { + if (count & 1) { + result += string; + } + if ((count >>= 1)) { + string += string; + } + } + return result; + }; +} + +if (!String.prototype.includes) { + String.prototype.includes = function(search, start) { + 'use strict'; + if (typeof start !== 'number') { + start = 0; + } + + if (start + search.length > this.length) { + return false; + } else { + return this.indexOf(search, start) !== -1; + } + }; +} diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/__tests__/Object.es7-test.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/__tests__/Object.es7-test.js new file mode 100644 index 00000000..9024d97f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/__tests__/Object.es7-test.js @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2013-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. + * + * @emails oncall+jsinfra + */ + +/* eslint-disable fb-www/object-create-only-one-param */ + +'use strict'; + +jest.disableAutomock(); + +describe('Object (ES7)', () => { + beforeEach(() => { + delete Object.entries; + delete Object.values; + jest.resetModules(); + require('../Object.es7'); + }); + + describe('Object.entries', () => { + it('should have a length of 1', () => { + expect(Object.entries.length).toBe(1); + }); + + it('should check for type', () => { + expect(Object.entries.bind(null, null)).toThrow(TypeError( + 'Object.entries called on non-object' + )); + expect(Object.entries.bind(null, undefined)).toThrow(TypeError( + 'Object.entries called on non-object' + )); + expect(Object.entries.bind(null, [])).not.toThrow(); + expect(Object.entries.bind(null, () => {})).not.toThrow(); + expect(Object.entries.bind(null, {})).not.toThrow(); + expect(Object.entries.bind(null, 'abc')).not.toThrow(); + }); + + it('should return enumerable entries', () => { + const foo = Object.defineProperties({}, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.entries(foo)).toEqual([['x', 10]]); + + const bar = {x: 10, y: 20}; + expect(Object.entries(bar)).toEqual([['x', 10], ['y', 20]]); + }); + + it('should work with proto-less objects', () => { + const foo = Object.create(null, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.entries(foo)).toEqual([['x', 10]]); + }); + + it('should return only own entries', () => { + const foo = Object.create({z: 30}, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.entries(foo)).toEqual([['x', 10]]); + }); + + it('should convert to object primitive string', () => { + expect(Object.entries('ab')).toEqual([['0', 'a'], ['1', 'b']]); + }); + }); + + describe('Object.values', () => { + it('should have a length of 1', () => { + expect(Object.values.length).toBe(1); + }); + + it('should check for type', () => { + expect(Object.values.bind(null, null)).toThrow(TypeError( + 'Object.values called on non-object' + )); + expect(Object.values.bind(null, [])).not.toThrow(); + expect(Object.values.bind(null, () => {})).not.toThrow(); + expect(Object.values.bind(null, {})).not.toThrow(); + }); + + it('should return enumerable values', () => { + const foo = Object.defineProperties({}, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.values(foo)).toEqual([10]); + + const bar = {x: 10, y: 20}; + expect(Object.values(bar)).toEqual([10, 20]); + }); + + it('should work with proto-less objects', () => { + const foo = Object.create(null, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.values(foo)).toEqual([10]); + }); + + it('should return only own values', () => { + const foo = Object.create({z: 30}, { + x: {value: 10, enumerable: true}, + y: {value: 20}, + }); + + expect(Object.values(foo)).toEqual([10]); + }); + + it('should convert to object primitive string', () => { + expect(Object.values('ab')).toEqual(['a', 'b']); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/babelHelpers.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/babelHelpers.js new file mode 100644 index 00000000..1390ef6d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/babelHelpers.js @@ -0,0 +1,237 @@ +/** + * 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. + * + * @polyfill + */ + +/* eslint-disable */ + +// Created by running: +// require('babel-core').buildExternalHelpers('_extends classCallCheck createClass createRawReactElement defineProperty get inherits interopRequireDefault interopRequireWildcard objectWithoutProperties possibleConstructorReturn slicedToArray taggedTemplateLiteral toArray toConsumableArray '.split(' ')) +// then replacing the `global` reference in the last line to also use `this`. +// +// actually, that's a lie, because babel6 omits _extends and createRawReactElement + +var babelHelpers = global.babelHelpers = {}; + +babelHelpers.typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { + return typeof obj; +} : function (obj) { + return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; +}; + +babelHelpers.createRawReactElement = (function () { + var REACT_ELEMENT_TYPE = typeof Symbol === "function" && Symbol.for && Symbol.for("react.element") || 0xeac7; + return function createRawReactElement(type, key, props) { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: type, + key: key, + ref: null, + props: props, + _owner: null + }; + }; +})(); + +babelHelpers.classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } +}; + +babelHelpers.createClass = (function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; +})(); + +babelHelpers.defineProperty = function (obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; +}; + +babelHelpers._extends = babelHelpers.extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; +}; + +babelHelpers.get = function get(object, property, receiver) { + if (object === null) object = Function.prototype; + var desc = Object.getOwnPropertyDescriptor(object, property); + + if (desc === undefined) { + var parent = Object.getPrototypeOf(object); + + if (parent === null) { + return undefined; + } else { + return get(parent, property, receiver); + } + } else if ("value" in desc) { + return desc.value; + } else { + var getter = desc.get; + + if (getter === undefined) { + return undefined; + } + + return getter.call(receiver); + } +}; + +babelHelpers.inherits = function (subClass, superClass) { + if (typeof superClass !== "function" && superClass !== null) { + throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); + } + + subClass.prototype = Object.create(superClass && superClass.prototype, { + constructor: { + value: subClass, + enumerable: false, + writable: true, + configurable: true + } + }); + if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; +}; + +babelHelpers.interopRequireDefault = function (obj) { + return obj && obj.__esModule ? obj : { + default: obj + }; +}; + +babelHelpers.interopRequireWildcard = function (obj) { + if (obj && obj.__esModule) { + return obj; + } else { + var newObj = {}; + + if (obj != null) { + for (var key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; + } + } + + newObj.default = obj; + return newObj; + } +}; + +babelHelpers.objectWithoutProperties = function (obj, keys) { + var target = {}; + + for (var i in obj) { + if (keys.indexOf(i) >= 0) continue; + if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; + target[i] = obj[i]; + } + + return target; +}; + +babelHelpers.possibleConstructorReturn = function (self, call) { + if (!self) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return call && (typeof call === "object" || typeof call === "function") ? call : self; +}; + +babelHelpers.slicedToArray = (function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; +})(); + +babelHelpers.taggedTemplateLiteral = function (strings, raw) { + return Object.freeze(Object.defineProperties(strings, { + raw: { + value: Object.freeze(raw) + } + })); +}; + +babelHelpers.toArray = function (arr) { + return Array.isArray(arr) ? arr : Array.from(arr); +}; + +babelHelpers.toConsumableArray = function (arr) { + if (Array.isArray(arr)) { + for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; + + return arr2; + } else { + return Array.from(arr); + } +}; diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/console.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/console.js new file mode 100644 index 00000000..9e5b7ebb --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/console.js @@ -0,0 +1,515 @@ +/** + * 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. + * + * @provides console + * @polyfill + * @nolint + */ + +/* eslint-disable */ + +/** + * This pipes all of our console logging functions to native logging so that + * JavaScript errors in required modules show up in Xcode via NSLog. + */ +const inspect = (function() { + // Copyright Joyent, Inc. and other Node contributors. + // + // Permission is hereby granted, free of charge, to any person obtaining a + // copy of this software and associated documentation files (the + // "Software"), to deal in the Software without restriction, including + // without limitation the rights to use, copy, modify, merge, publish, + // distribute, sublicense, and/or sell copies of the Software, and to permit + // persons to whom the Software is furnished to do so, subject to the + // following conditions: + // + // The above copyright notice and this permission notice shall be included + // in all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + // USE OR OTHER DEALINGS IN THE SOFTWARE. + // + // https://github.com/joyent/node/blob/master/lib/util.js + + function inspect(obj, opts) { + var ctx = { + seen: [], + stylize: stylizeNoColor + }; + return formatValue(ctx, obj, opts.depth); + } + + function stylizeNoColor(str, styleType) { + return str; + } + + function arrayToHash(array) { + var hash = {}; + + array.forEach(function(val, idx) { + hash[val] = true; + }); + + return hash; + } + + + function formatValue(ctx, value, recurseTimes) { + // Primitive types cannot have properties + var primitive = formatPrimitive(ctx, value); + if (primitive) { + return primitive; + } + + // Look up the keys of the object. + var keys = Object.keys(value); + var visibleKeys = arrayToHash(keys); + + // IE doesn't make error fields non-enumerable + // http://msdn.microsoft.com/en-us/library/ie/dww52sbt(v=vs.94).aspx + if (isError(value) + && (keys.indexOf('message') >= 0 || keys.indexOf('description') >= 0)) { + return formatError(value); + } + + // Some type of object without properties can be shortcutted. + if (keys.length === 0) { + if (isFunction(value)) { + var name = value.name ? ': ' + value.name : ''; + return ctx.stylize('[Function' + name + ']', 'special'); + } + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); + } + if (isDate(value)) { + return ctx.stylize(Date.prototype.toString.call(value), 'date'); + } + if (isError(value)) { + return formatError(value); + } + } + + var base = '', array = false, braces = ['{', '}']; + + // Make Array say that they are Array + if (isArray(value)) { + array = true; + braces = ['[', ']']; + } + + // Make functions say that they are functions + if (isFunction(value)) { + var n = value.name ? ': ' + value.name : ''; + base = ' [Function' + n + ']'; + } + + // Make RegExps say that they are RegExps + if (isRegExp(value)) { + base = ' ' + RegExp.prototype.toString.call(value); + } + + // Make dates with properties first say the date + if (isDate(value)) { + base = ' ' + Date.prototype.toUTCString.call(value); + } + + // Make error with message first say the error + if (isError(value)) { + base = ' ' + formatError(value); + } + + if (keys.length === 0 && (!array || value.length == 0)) { + return braces[0] + base + braces[1]; + } + + if (recurseTimes < 0) { + if (isRegExp(value)) { + return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); + } else { + return ctx.stylize('[Object]', 'special'); + } + } + + ctx.seen.push(value); + + var output; + if (array) { + output = formatArray(ctx, value, recurseTimes, visibleKeys, keys); + } else { + output = keys.map(function(key) { + return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array); + }); + } + + ctx.seen.pop(); + + return reduceToSingleString(output, base, braces); + } + + + function formatPrimitive(ctx, value) { + if (isUndefined(value)) + return ctx.stylize('undefined', 'undefined'); + if (isString(value)) { + var simple = '\'' + JSON.stringify(value).replace(/^"|"$/g, '') + .replace(/'/g, "\\'") + .replace(/\\"/g, '"') + '\''; + return ctx.stylize(simple, 'string'); + } + if (isNumber(value)) + return ctx.stylize('' + value, 'number'); + if (isBoolean(value)) + return ctx.stylize('' + value, 'boolean'); + // For some reason typeof null is "object", so special case here. + if (isNull(value)) + return ctx.stylize('null', 'null'); + } + + + function formatError(value) { + return '[' + Error.prototype.toString.call(value) + ']'; + } + + + function formatArray(ctx, value, recurseTimes, visibleKeys, keys) { + var output = []; + for (var i = 0, l = value.length; i < l; ++i) { + if (hasOwnProperty(value, String(i))) { + output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, + String(i), true)); + } else { + output.push(''); + } + } + keys.forEach(function(key) { + if (!key.match(/^\d+$/)) { + output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, + key, true)); + } + }); + return output; + } + + + function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) { + var name, str, desc; + desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] }; + if (desc.get) { + if (desc.set) { + str = ctx.stylize('[Getter/Setter]', 'special'); + } else { + str = ctx.stylize('[Getter]', 'special'); + } + } else { + if (desc.set) { + str = ctx.stylize('[Setter]', 'special'); + } + } + if (!hasOwnProperty(visibleKeys, key)) { + name = '[' + key + ']'; + } + if (!str) { + if (ctx.seen.indexOf(desc.value) < 0) { + if (isNull(recurseTimes)) { + str = formatValue(ctx, desc.value, null); + } else { + str = formatValue(ctx, desc.value, recurseTimes - 1); + } + if (str.indexOf('\n') > -1) { + if (array) { + str = str.split('\n').map(function(line) { + return ' ' + line; + }).join('\n').substr(2); + } else { + str = '\n' + str.split('\n').map(function(line) { + return ' ' + line; + }).join('\n'); + } + } + } else { + str = ctx.stylize('[Circular]', 'special'); + } + } + if (isUndefined(name)) { + if (array && key.match(/^\d+$/)) { + return str; + } + name = JSON.stringify('' + key); + if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { + name = name.substr(1, name.length - 2); + name = ctx.stylize(name, 'name'); + } else { + name = name.replace(/'/g, "\\'") + .replace(/\\"/g, '"') + .replace(/(^"|"$)/g, "'"); + name = ctx.stylize(name, 'string'); + } + } + + return name + ': ' + str; + } + + + function reduceToSingleString(output, base, braces) { + var numLinesEst = 0; + var length = output.reduce(function(prev, cur) { + numLinesEst++; + if (cur.indexOf('\n') >= 0) numLinesEst++; + return prev + cur.replace(/\u001b\[\d\d?m/g, '').length + 1; + }, 0); + + if (length > 60) { + return braces[0] + + (base === '' ? '' : base + '\n ') + + ' ' + + output.join(',\n ') + + ' ' + + braces[1]; + } + + return braces[0] + base + ' ' + output.join(', ') + ' ' + braces[1]; + } + + + // NOTE: These type checking functions intentionally don't use `instanceof` + // because it is fragile and can be easily faked with `Object.create()`. + function isArray(ar) { + return Array.isArray(ar); + } + + function isBoolean(arg) { + return typeof arg === 'boolean'; + } + + function isNull(arg) { + return arg === null; + } + + function isNullOrUndefined(arg) { + return arg == null; + } + + function isNumber(arg) { + return typeof arg === 'number'; + } + + function isString(arg) { + return typeof arg === 'string'; + } + + function isSymbol(arg) { + return typeof arg === 'symbol'; + } + + function isUndefined(arg) { + return arg === void 0; + } + + function isRegExp(re) { + return isObject(re) && objectToString(re) === '[object RegExp]'; + } + + function isObject(arg) { + return typeof arg === 'object' && arg !== null; + } + + function isDate(d) { + return isObject(d) && objectToString(d) === '[object Date]'; + } + + function isError(e) { + return isObject(e) && + (objectToString(e) === '[object Error]' || e instanceof Error); + } + + function isFunction(arg) { + return typeof arg === 'function'; + } + + function isPrimitive(arg) { + return arg === null || + typeof arg === 'boolean' || + typeof arg === 'number' || + typeof arg === 'string' || + typeof arg === 'symbol' || // ES6 symbol + typeof arg === 'undefined'; + } + + function objectToString(o) { + return Object.prototype.toString.call(o); + } + + function hasOwnProperty(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); + } + + return inspect; +})(); + + +const OBJECT_COLUMN_NAME = '(index)'; +const LOG_LEVELS = { + trace: 0, + info: 1, + warn: 2, + error: 3 +}; +const INSPECTOR_LEVELS = []; +INSPECTOR_LEVELS[LOG_LEVELS.trace] = 'debug'; +INSPECTOR_LEVELS[LOG_LEVELS.info] = 'log'; +INSPECTOR_LEVELS[LOG_LEVELS.warn] = 'warning'; +INSPECTOR_LEVELS[LOG_LEVELS.error] = 'error'; + +// Strip the inner function in getNativeLogFunction(), if in dev also +// strip method printing to originalConsole. +const INSPECTOR_FRAMES_TO_SKIP = __DEV__ ? 2 : 1; + +function setupConsole(global) { + if (!global.nativeLoggingHook) { + return; + } + + function getNativeLogFunction(level) { + return function() { + let str; + if (arguments.length === 1 && typeof arguments[0] === 'string') { + str = arguments[0]; + } else { + str = Array.prototype.map.call(arguments, function(arg) { + return inspect(arg, {depth: 10}); + }).join(', '); + } + + let logLevel = level; + if (str.slice(0, 9) === 'Warning: ' && logLevel >= LOG_LEVELS.error) { + // React warnings use console.error so that a stack trace is shown, + // but we don't (currently) want these to show a redbox + // (Note: Logic duplicated in ExceptionsManager.js.) + logLevel = LOG_LEVELS.warn; + } + if (global.__inspectorLog) { + global.__inspectorLog( + INSPECTOR_LEVELS[logLevel], + str, + [].slice.call(arguments), + INSPECTOR_FRAMES_TO_SKIP); + } + global.nativeLoggingHook(str, logLevel); + }; + } + + function repeat(element, n) { + return Array.apply(null, Array(n)).map(function() { return element; }); + }; + + function consoleTablePolyfill(rows) { + // convert object -> array + if (!Array.isArray(rows)) { + var data = rows; + rows = []; + for (var key in data) { + if (data.hasOwnProperty(key)) { + var row = data[key]; + row[OBJECT_COLUMN_NAME] = key; + rows.push(row); + } + } + } + if (rows.length === 0) { + global.nativeLoggingHook('', LOG_LEVELS.info); + return; + } + + var columns = Object.keys(rows[0]).sort(); + var stringRows = []; + var columnWidths = []; + + // Convert each cell to a string. Also + // figure out max cell width for each column + columns.forEach(function(k, i) { + columnWidths[i] = k.length; + for (var j = 0; j < rows.length; j++) { + var cellStr = (rows[j][k] || '?').toString(); + stringRows[j] = stringRows[j] || []; + stringRows[j][i] = cellStr; + columnWidths[i] = Math.max(columnWidths[i], cellStr.length); + } + }); + + // Join all elements in the row into a single string with | separators + // (appends extra spaces to each cell to make separators | alligned) + function joinRow(row, space) { + var cells = row.map(function(cell, i) { + var extraSpaces = repeat(' ', columnWidths[i] - cell.length).join(''); + return cell + extraSpaces; + }); + space = space || ' '; + return cells.join(space + '|' + space); + }; + + var separators = columnWidths.map(function(columnWidth) { + return repeat('-', columnWidth).join(''); + }); + var separatorRow = joinRow(separators, '-'); + var header = joinRow(columns); + var table = [header, separatorRow]; + + for (var i = 0; i < rows.length; i++) { + table.push(joinRow(stringRows[i])); + } + + // Notice extra empty line at the beginning. + // Native logging hook adds "RCTLog >" at the front of every + // logged string, which would shift the header and screw up + // the table + global.nativeLoggingHook('\n' + table.join('\n'), LOG_LEVELS.info); + } + + // Preserve the original `console` as `originalConsole` + var originalConsole = global.console; + var descriptor = Object.getOwnPropertyDescriptor(global, 'console'); + if (descriptor) { + Object.defineProperty(global, 'originalConsole', descriptor); + } + + global.console = { + error: getNativeLogFunction(LOG_LEVELS.error), + info: getNativeLogFunction(LOG_LEVELS.info), + log: getNativeLogFunction(LOG_LEVELS.info), + warn: getNativeLogFunction(LOG_LEVELS.warn), + trace: getNativeLogFunction(LOG_LEVELS.trace), + debug: getNativeLogFunction(LOG_LEVELS.trace), + table: consoleTablePolyfill + }; + + // If available, also call the original `console` method since that is + // sometimes useful. Ex: on OS X, this will let you see rich output in + // the Safari Web Inspector console. + if (__DEV__ && originalConsole) { + Object.keys(console).forEach(methodName => { + var reactNativeMethod = console[methodName]; + if (originalConsole[methodName]) { + console[methodName] = function() { + originalConsole[methodName](...arguments); + reactNativeMethod.apply(console, arguments); + }; + } + }); + } +} + +if (typeof module !== 'undefined') { + module.exports = setupConsole; +} else { + setupConsole(global); +} diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/error-guard.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/error-guard.js new file mode 100644 index 00000000..feb3e761 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/error-guard.js @@ -0,0 +1,88 @@ +/** + * 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. + * + * @polyfill + */ + +/* eslint-disable strict */ + +let _inGuard = 0; + +/** + * This is the error handler that is called when we encounter an exception + * when loading a module. This will report any errors encountered before + * ExceptionsManager is configured. + */ +let _globalHandler = function onError(e) { + throw e; +}; + +/** + * The particular require runtime that we are using looks for a global + * `ErrorUtils` object and if it exists, then it requires modules with the + * error handler specified via ErrorUtils.setGlobalHandler by calling the + * require function with applyWithGuard. Since the require module is loaded + * before any of the modules, this ErrorUtils must be defined (and the handler + * set) globally before requiring anything. + */ +const ErrorUtils = { + setGlobalHandler: function(fun) { + _globalHandler = fun; + }, + getGlobalHandler: function() { + return _globalHandler; + }, + reportError: function(error) { + _globalHandler && _globalHandler(error); + }, + reportFatalError: function(error) { + _globalHandler && _globalHandler(error, true); + }, + applyWithGuard: function(fun, context, args) { + try { + _inGuard++; + return fun.apply(context, args); + } catch (e) { + ErrorUtils.reportError(e); + } finally { + _inGuard--; + } + }, + applyWithGuardIfNeeded: function(fun, context, args) { + if (ErrorUtils.inGuard()) { + return fun.apply(context, args); + } else { + ErrorUtils.applyWithGuard(fun, context, args); + } + }, + inGuard: function() { + return _inGuard; + }, + guard: function(fun, name, context) { + if (typeof fun !== 'function') { + console.warn('A function must be passed to ErrorUtils.guard, got ', fun); + return null; + } + name = name || fun.name || ''; + function guarded() { + return ( + ErrorUtils.applyWithGuard( + fun, + context || this, + arguments, + null, + name + ) + ); + } + + return guarded; + } +}; + +global.ErrorUtils = ErrorUtils; diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/polyfills.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/polyfills.js new file mode 100644 index 00000000..db656714 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/polyfills.js @@ -0,0 +1,69 @@ +/** + * 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. + * + * @provides Object.es6 + * @polyfill + */ + +/* eslint-disable strict */ + +// WARNING: This is an optimized version that fails on hasOwnProperty checks +// and non objects. It's not spec-compliant. It's a perf optimization. +// This is only needed for iOS 8 and current Android JSC. + +Object.assign = function(target, sources) { + if (__DEV__) { + if (target == null) { + throw new TypeError('Object.assign target cannot be null or undefined'); + } + if (typeof target !== 'object' && typeof target !== 'function') { + throw new TypeError( + 'In this environment the target of assign MUST be an object.' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + + for (var nextIndex = 1; nextIndex < arguments.length; nextIndex++) { + var nextSource = arguments[nextIndex]; + if (nextSource == null) { + continue; + } + + if (__DEV__) { + if (typeof nextSource !== 'object' && + typeof nextSource !== 'function') { + throw new TypeError( + 'In this environment the sources for assign MUST be an object.' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + + // We don't currently support accessors nor proxies. Therefore this + // copy cannot throw. If we ever supported this then we must handle + // exceptions and side-effects. + + for (var key in nextSource) { + if (__DEV__) { + var hasOwnProperty = Object.prototype.hasOwnProperty; + if (!hasOwnProperty.call(nextSource, key)) { + throw new TypeError( + 'One of the sources for assign has an enumerable key on the ' + + 'prototype chain. Are you trying to assign a prototype property? ' + + 'We don\'t allow it, as this is an edge case that we do not support. ' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + target[key] = nextSource[key]; + } + } + + return target; +}; diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude.js new file mode 100644 index 00000000..0451a75b --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2013-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. + * + * @polyfill + */ + +/* eslint-disable strict */ + +global.__DEV__ = false; + +global.__BUNDLE_START_TIME__ = Date.now(); diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude_dev.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude_dev.js new file mode 100644 index 00000000..0f086fb9 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/prelude_dev.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2013-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. + * + * @polyfill + */ + +/* eslint-disable strict */ + +global.__DEV__ = true; + +global.__BUNDLE_START_TIME__ = Date.now(); diff --git a/packages/metro-bundler/react-packager/src/Resolver/polyfills/require.js b/packages/metro-bundler/react-packager/src/Resolver/polyfills/require.js new file mode 100644 index 00000000..27f81a92 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Resolver/polyfills/require.js @@ -0,0 +1,285 @@ +/** + * Copyright (c) 2013-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. + * + * @polyfill + * @flow + */ + +'use strict'; + +type DependencyMap = Array; +type Exports = any; +type FactoryFn = ( + global: Object, + require: RequireFn, + moduleObject: {exports: {}}, + exports: {}, + dependencyMap: ?DependencyMap, +) => void; +type HotModuleReloadingAcceptFn = Function; +type HotModuleReloadingData = {| + acceptCallback: ?HotModuleReloadingAcceptFn, + accept: (callback: HotModuleReloadingAcceptFn) => void, +|}; +type Module = { + exports: Exports, + hot?: HotModuleReloadingData, +}; +type ModuleID = number; +type ModuleDefinition = {| + dependencyMap: ?DependencyMap, + exports: Exports, + factory: FactoryFn, + hasError: boolean, + hot?: HotModuleReloadingData, + isInitialized: boolean, + verboseName?: string, +|}; +type ModuleMap = + {[key: ModuleID]: (ModuleDefinition)}; +type RequireFn = (id: ModuleID | VerboseModuleNameForDev) => Exports; +type VerboseModuleNameForDev = string; + +global.require = require; +global.__d = define; + +const modules: ModuleMap = Object.create(null); +if (__DEV__) { + var verboseNamesToModuleIds: {[key: string]: number} = Object.create(null); +} + +function define( + factory: FactoryFn, + moduleId: number, + dependencyMap?: DependencyMap, +) { + if (moduleId in modules) { + // prevent repeated calls to `global.nativeRequire` to overwrite modules + // that are already loaded + return; + } + modules[moduleId] = { + dependencyMap, + exports: undefined, + factory, + hasError: false, + isInitialized: false, + }; + if (__DEV__) { + // HMR + modules[moduleId].hot = createHotReloadingObject(); + + // DEBUGGABLE MODULES NAMES + // we take `verboseName` from `arguments` to avoid an unused named parameter + // in `define` in production. + const verboseName: string | void = arguments[3]; + if (verboseName) { + modules[moduleId].verboseName = verboseName; + verboseNamesToModuleIds[verboseName] = moduleId; + } + } +} + +function require(moduleId: ModuleID | VerboseModuleNameForDev) { + if (__DEV__ && typeof moduleId === 'string') { + const verboseName = moduleId; + moduleId = verboseNamesToModuleIds[moduleId]; + if (moduleId == null) { + throw new Error(`Unknown named module: '${verboseName}'`); + } else { + console.warn( + `Requiring module '${verboseName}' by name is only supported for ` + + 'debugging purposes and will BREAK IN PRODUCTION!' + ); + } + } + + //$FlowFixMe: at this point we know that moduleId is a number + const moduleIdReallyIsNumber: number = moduleId; + const module = modules[moduleIdReallyIsNumber]; + return module && module.isInitialized + ? module.exports + : guardedLoadModule(moduleIdReallyIsNumber, module); +} + +let inGuard = false; +function guardedLoadModule(moduleId: ModuleID , module) { + if (!inGuard && global.ErrorUtils) { + inGuard = true; + let returnValue; + try { + returnValue = loadModuleImplementation(moduleId, module); + } catch (e) { + global.ErrorUtils.reportFatalError(e); + } + inGuard = false; + return returnValue; + } else { + return loadModuleImplementation(moduleId, module); + } +} + +function loadModuleImplementation(moduleId, module) { + const nativeRequire = global.nativeRequire; + if (!module && nativeRequire) { + nativeRequire(moduleId); + module = modules[moduleId]; + } + + if (!module) { + throw unknownModuleError(moduleId); + } + + if (module.hasError) { + throw moduleThrewError(moduleId); + } + + // `require` calls int the require polyfill itself are not analyzed and + // replaced so that they use numeric module IDs. + // The systrace module will expose itself on the require function so that + // it can be used here. + // TODO(davidaurelio) Scan polyfills for dependencies, too (t9759686) + if (__DEV__) { + var {Systrace} = require; + } + + // We must optimistically mark module as initialized before running the + // factory to keep any require cycles inside the factory from causing an + // infinite require loop. + module.isInitialized = true; + const exports = module.exports = {}; + const {factory, dependencyMap} = module; + try { + if (__DEV__) { + // $FlowFixMe: we know that __DEV__ is const and `Systrace` exists + Systrace.beginEvent('JS_require_' + (module.verboseName || moduleId)); + } + + const moduleObject: Module = {exports}; + if (__DEV__ && module.hot) { + moduleObject.hot = module.hot; + } + + // keep args in sync with with defineModuleCode in + // packager/react-packager/src/Resolver/index.js + // and packager/react-packager/src/ModuleGraph/worker.js + factory(global, require, moduleObject, exports, dependencyMap); + + // avoid removing factory in DEV mode as it breaks HMR + if (!__DEV__) { + // $FlowFixMe: This is only sound because we never access `factory` again + module.factory = undefined; + } + + if (__DEV__) { + // $FlowFixMe: we know that __DEV__ is const and `Systrace` exists + Systrace.endEvent(); + } + return (module.exports = moduleObject.exports); + } catch (e) { + module.hasError = true; + module.isInitialized = false; + module.exports = undefined; + throw e; + } +} + +function unknownModuleError(id) { + let message = 'Requiring unknown module "' + id + '".'; + if (__DEV__) { + message += + 'If you are sure the module is there, try restarting the packager or running "npm install".'; + } + return Error(message); +} + +function moduleThrewError(id) { + return Error('Requiring module "' + id + '", which threw an exception.'); +} + +if (__DEV__) { + require.Systrace = { beginEvent: () => {}, endEvent: () => {} }; + + // HOT MODULE RELOADING + var createHotReloadingObject = function() { + const hot: HotModuleReloadingData = { + acceptCallback: null, + accept: callback => { hot.acceptCallback = callback; }, + }; + return hot; + }; + + const acceptAll = function( + dependentModules, + inverseDependencies, + ) { + if (!dependentModules || dependentModules.length === 0) { + return true; + } + + const notAccepted = dependentModules.filter( + module => !accept(module, /*factory*/ undefined, inverseDependencies)); + + const parents = []; + for (let i = 0; i < notAccepted.length; i++) { + // if the module has no parents then the change cannot be hot loaded + if (inverseDependencies[notAccepted[i]].length === 0) { + return false; + } + + parents.push(...inverseDependencies[notAccepted[i]]); + } + + return acceptAll(parents, inverseDependencies); + }; + + const accept = function( + id: ModuleID, + factory?: FactoryFn, + inverseDependencies: {[key: ModuleID]: Array}, + ) { + const mod = modules[id]; + + if (!mod && factory) { // new modules need a factory + define(factory, id); + return true; // new modules don't need to be accepted + } + + const {hot} = mod; + if (!hot) { + console.warn( + 'Cannot accept module because Hot Module Replacement ' + + 'API was not installed.' + ); + return false; + } + + // replace and initialize factory + if (factory) { + mod.factory = factory; + } + mod.hasError = false; + mod.isInitialized = false; + require(id); + + if (hot.acceptCallback) { + hot.acceptCallback(); + return true; + } else { + // need to have inverseDependencies to bubble up accept + if (!inverseDependencies) { + throw new Error('Undefined `inverseDependencies`'); + } + + // accept parent modules recursively up until all siblings are accepted + return acceptAll(inverseDependencies[id], inverseDependencies); + } + }; + + global.__accept = accept; +} diff --git a/packages/metro-bundler/react-packager/src/Server/MultipartResponse.js b/packages/metro-bundler/react-packager/src/Server/MultipartResponse.js new file mode 100644 index 00000000..d249de9f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Server/MultipartResponse.js @@ -0,0 +1,84 @@ +/** + * 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'; + +const CRLF = '\r\n'; +const BOUNDARY = '3beqjf3apnqeu3h5jqorms4i'; + +class MultipartResponse { + static wrap(req, res) { + if (acceptsMultipartResponse(req)) { + return new MultipartResponse(res); + } + // Ugly hack, ideally wrap function should always return a proxy + // object with the same interface + res.writeChunk = () => {}; // noop + return res; + } + + constructor(res) { + this.res = res; + this.headers = {}; + + res.writeHead(200, { + 'Content-Type': `multipart/mixed; boundary="${BOUNDARY}"`, + }); + res.write('If you are seeing this, your client does not support multipart response'); + } + + writeChunk(headers, data, isLast = false) { + let chunk = `${CRLF}--${BOUNDARY}${CRLF}`; + if (headers) { + chunk += MultipartResponse.serializeHeaders(headers) + CRLF + CRLF; + } + + if (data) { + chunk += data; + } + + if (isLast) { + chunk += `${CRLF}--${BOUNDARY}--${CRLF}`; + } + + this.res.write(chunk); + } + + writeHead(status, headers) { + // We can't actually change the response HTTP status code + // because the headers have already been sent + this.setHeader('X-Http-Status', status); + if (!headers) { + return; + } + for (const key in headers) { + this.setHeader(key, headers[key]); + } + } + + setHeader(name, value) { + this.headers[name] = value; + } + + end(data) { + this.writeChunk(this.headers, data, true); + this.res.end(); + } + + static serializeHeaders(headers) { + return Object.keys(headers) + .map((key) => `${key}: ${headers[key]}`) + .join(CRLF); + } +} + +function acceptsMultipartResponse(req) { + return req.headers && req.headers.accept === 'multipart/mixed'; +} + +module.exports = MultipartResponse; diff --git a/packages/metro-bundler/react-packager/src/Server/__tests__/MultipartResponse-test.js b/packages/metro-bundler/react-packager/src/Server/__tests__/MultipartResponse-test.js new file mode 100644 index 00000000..db751d74 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Server/__tests__/MultipartResponse-test.js @@ -0,0 +1,150 @@ +/** + * 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.dontMock('../MultipartResponse'); + +const MultipartResponse = require('../MultipartResponse'); + +describe('MultipartResponse', () => { + it('forwards calls to response', () => { + const nreq = mockNodeRequest({accept: 'text/html'}); + const nres = mockNodeResponse(); + const res = MultipartResponse.wrap(nreq, nres); + + expect(res).toBe(nres); + + res.writeChunk({}, 'foo'); + expect(nres.write).not.toBeCalled(); + }); + + it('writes multipart response', () => { + const nreq = mockNodeRequest({accept: 'multipart/mixed'}); + const nres = mockNodeResponse(); + const res = MultipartResponse.wrap(nreq, nres); + + expect(res).not.toBe(nres); + + res.setHeader('Result-Header-1', 1); + res.writeChunk({foo: 'bar'}, 'first chunk'); + res.writeChunk({test: 2}, 'second chunk'); + res.writeChunk(null, 'empty headers third chunk'); + res.setHeader('Result-Header-2', 2); + res.end('Hello, world!'); + + expect(nres.toString()).toEqual([ + 'HTTP/1.1 200', + 'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"', + '', + 'If you are seeing this, your client does not support multipart response', + '--3beqjf3apnqeu3h5jqorms4i', + 'foo: bar', + '', + 'first chunk', + '--3beqjf3apnqeu3h5jqorms4i', + 'test: 2', + '', + 'second chunk', + '--3beqjf3apnqeu3h5jqorms4i', + 'empty headers third chunk', + '--3beqjf3apnqeu3h5jqorms4i', + 'Result-Header-1: 1', + 'Result-Header-2: 2', + '', + 'Hello, world!', + '--3beqjf3apnqeu3h5jqorms4i--', + '', + ].join('\r\n')); + }); + + it('sends status code as last chunk header', () => { + const nreq = mockNodeRequest({accept: 'multipart/mixed'}); + const nres = mockNodeResponse(); + const res = MultipartResponse.wrap(nreq, nres); + + res.writeChunk({foo: 'bar'}, 'first chunk'); + res.writeHead(500, { + 'Content-Type': 'application/json; boundary="3beqjf3apnqeu3h5jqorms4i"', + }); + res.end('{}'); + + expect(nres.toString()).toEqual([ + 'HTTP/1.1 200', + 'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"', + '', + 'If you are seeing this, your client does not support multipart response', + '--3beqjf3apnqeu3h5jqorms4i', + 'foo: bar', + '', + 'first chunk', + '--3beqjf3apnqeu3h5jqorms4i', + 'X-Http-Status: 500', + 'Content-Type: application/json; boundary="3beqjf3apnqeu3h5jqorms4i"', + '', + '{}', + '--3beqjf3apnqeu3h5jqorms4i--', + '', + ].join('\r\n')); + }); + + it('supports empty responses', () => { + const nreq = mockNodeRequest({accept: 'multipart/mixed'}); + const nres = mockNodeResponse(); + const res = MultipartResponse.wrap(nreq, nres); + + res.writeHead(304, { + 'Content-Type': 'application/json; boundary="3beqjf3apnqeu3h5jqorms4i"', + }); + res.end(); + + expect(nres.toString()).toEqual([ + 'HTTP/1.1 200', + 'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"', + '', + 'If you are seeing this, your client does not support multipart response', + '--3beqjf3apnqeu3h5jqorms4i', + 'X-Http-Status: 304', + 'Content-Type: application/json; boundary="3beqjf3apnqeu3h5jqorms4i"', + '', + '', + '--3beqjf3apnqeu3h5jqorms4i--', + '', + ].join('\r\n')); + }); +}); + +function mockNodeRequest(headers = {}) { + return {headers}; +} + +function mockNodeResponse() { + let status = 200; + let headers = {}; + let body = ''; + return { + writeHead: jest.fn((st, hdrs) => { + status = st; + headers = {...headers, ...hdrs}; + }), + setHeader: jest.fn((key, val) => { headers[key] = val; }), + write: jest.fn((data) => { body += data; }), + end: jest.fn((data) => { body += (data || ''); }), + + // For testing only + toString() { + return [ + `HTTP/1.1 ${status}`, + MultipartResponse.serializeHeaders(headers), + '', + body, + ].join('\r\n'); + } + }; +} + diff --git a/packages/metro-bundler/react-packager/src/Server/__tests__/Server-test.js b/packages/metro-bundler/react-packager/src/Server/__tests__/Server-test.js new file mode 100644 index 00000000..bcfb2cfc --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Server/__tests__/Server-test.js @@ -0,0 +1,545 @@ +/** + * 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.disableAutomock(); + +jest.setMock('worker-farm', function() { return () => {}; }) + .setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) }) + .setMock('uglify-js') + .setMock('crypto') + .setMock('source-map', { SourceMapConsumer: function(fn) {}}) + .mock('../../Bundler') + .mock('../../AssetServer') + .mock('../../lib/declareOpts') + .mock('../../node-haste') + .mock('../../Logger') + .mock('../../lib/GlobalTransformCache'); + +describe('processRequest', () => { + let SourceMapConsumer, Bundler, Server, AssetServer, Promise; + beforeEach(() => { + jest.resetModules(); + SourceMapConsumer = require('source-map').SourceMapConsumer; + Bundler = require('../../Bundler'); + Server = require('../'); + AssetServer = require('../../AssetServer'); + Promise = require('promise'); + }); + + let server; + + const options = { + projectRoots: ['root'], + blacklistRE: null, + cacheVersion: null, + polyfillModuleNames: null, + reporter: require('../../lib/reporting').nullReporter, + }; + + const makeRequest = (reqHandler, requrl, reqOptions) => new Promise(resolve => + reqHandler( + { url: requrl, headers:{}, ...reqOptions }, + { + statusCode: 200, + headers: {}, + getHeader(header) { return this.headers[header]; }, + setHeader(header, value) { this.headers[header] = value; }, + writeHead(statusCode) { this.statusCode = statusCode; }, + end(body) { + this.body = body; + resolve(this); + }, + }, + { next: () => {} }, + ) + ); + + const invalidatorFunc = jest.fn(); + let requestHandler; + + beforeEach(() => { + Bundler.prototype.bundle = jest.fn(() => + Promise.resolve({ + getSource: () => 'this is the source', + getSourceMap: () => {}, + getSourceMapString: () => 'this is the source map', + getEtag: () => 'this is an etag', + })); + + Bundler.prototype.invalidateFile = invalidatorFunc; + Bundler.prototype.getResolver = + jest.fn().mockReturnValue({ + getDependencyGraph: jest.fn().mockReturnValue({ + getHasteMap: jest.fn().mockReturnValue({on: jest.fn()}), + load: jest.fn(() => Promise.resolve()), + }), + }); + + server = new Server(options); + requestHandler = server.processRequest.bind(server); + }); + + it('returns JS bundle source on request of *.bundle', () => { + return makeRequest( + requestHandler, + 'mybundle.bundle?runModule=true', + null + ).then(response => + expect(response.body).toEqual('this is the source') + ); + }); + + it('returns JS bundle source on request of *.bundle (compat)', () => { + return makeRequest( + requestHandler, + 'mybundle.runModule.bundle' + ).then(response => + expect(response.body).toEqual('this is the source') + ); + }); + + it('returns ETag header on request of *.bundle', () => { + return makeRequest( + requestHandler, + 'mybundle.bundle?runModule=true' + ).then(response => { + expect(response.getHeader('ETag')).toBeDefined(); + }); + }); + + it('returns 304 on request of *.bundle when if-none-match equals the ETag', () => { + return makeRequest( + requestHandler, + 'mybundle.bundle?runModule=true', + { headers : { 'if-none-match' : 'this is an etag' } } + ).then(response => { + expect(response.statusCode).toEqual(304); + }); + }); + + it('returns sourcemap on request of *.map', () => { + return makeRequest( + requestHandler, + 'mybundle.map?runModule=true' + ).then(response => + expect(response.body).toEqual('this is the source map') + ); + }); + + it('works with .ios.js extension', () => { + return makeRequest( + requestHandler, + 'index.ios.includeRequire.bundle' + ).then(response => { + expect(response.body).toEqual('this is the source'); + expect(Bundler.prototype.bundle).toBeCalledWith({ + entryFile: 'index.ios.js', + inlineSourceMap: false, + minify: false, + generateSourceMaps: false, + hot: false, + runModule: true, + sourceMapUrl: 'index.ios.includeRequire.map', + dev: true, + platform: undefined, + onProgress: jasmine.any(Function), + runBeforeMainModule: ['InitializeCore'], + unbundle: false, + entryModuleOnly: false, + isolateModuleIDs: false, + assetPlugins: [], + }); + }); + }); + + it('passes in the platform param', function() { + return makeRequest( + requestHandler, + 'index.bundle?platform=ios' + ).then(function(response) { + expect(response.body).toEqual('this is the source'); + expect(Bundler.prototype.bundle).toBeCalledWith({ + entryFile: 'index.js', + inlineSourceMap: false, + minify: false, + generateSourceMaps: false, + hot: false, + runModule: true, + sourceMapUrl: 'index.map?platform=ios', + dev: true, + platform: 'ios', + onProgress: jasmine.any(Function), + runBeforeMainModule: ['InitializeCore'], + unbundle: false, + entryModuleOnly: false, + isolateModuleIDs: false, + assetPlugins: [], + }); + }); + }); + + it('passes in the assetPlugin param', function() { + return makeRequest( + requestHandler, + 'index.bundle?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2' + ).then(function(response) { + expect(response.body).toEqual('this is the source'); + expect(Bundler.prototype.bundle).toBeCalledWith({ + entryFile: 'index.js', + inlineSourceMap: false, + minify: false, + generateSourceMaps: false, + hot: false, + runModule: true, + sourceMapUrl: 'index.map?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2', + dev: true, + platform: undefined, + onProgress: jasmine.any(Function), + runBeforeMainModule: ['InitializeCore'], + unbundle: false, + entryModuleOnly: false, + isolateModuleIDs: false, + assetPlugins: ['assetPlugin1', 'assetPlugin2'], + }); + }); + }); + + describe('file changes', () => { + it('invalides files in bundle when file is updated', () => { + return makeRequest( + requestHandler, + 'mybundle.bundle?runModule=true' + ).then(() => { + server.onFileChange('all', options.projectRoots[0] + '/path/file.js'); + expect(invalidatorFunc.mock.calls[0][0]).toEqual('root/path/file.js'); + }); + }); + + it('does not rebuild the bundles that contain a file when that file is changed', () => { + const bundleFunc = jest.fn(); + bundleFunc + .mockReturnValueOnce( + Promise.resolve({ + getSource: () => 'this is the first source', + getSourceMap: () => {}, + getSourceMapString: () => 'this is the source map', + getEtag: () => () => 'this is an etag', + }) + ) + .mockReturnValue( + Promise.resolve({ + getSource: () => 'this is the rebuilt source', + getSourceMap: () => {}, + getSourceMapString: () => 'this is the source map', + getEtag: () => () => 'this is an etag', + }) + ); + + Bundler.prototype.bundle = bundleFunc; + + server = new Server(options); + + requestHandler = server.processRequest.bind(server); + + makeRequest(requestHandler, 'mybundle.bundle?runModule=true') + .done(response => { + expect(response.body).toEqual('this is the first source'); + expect(bundleFunc.mock.calls.length).toBe(1); + }); + + jest.runAllTicks(); + + server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + jest.runAllTimers(); + jest.runAllTicks(); + + expect(bundleFunc.mock.calls.length).toBe(1); + + makeRequest(requestHandler, 'mybundle.bundle?runModule=true') + .done(response => + expect(response.body).toEqual('this is the rebuilt source') + ); + jest.runAllTicks(); + }); + + it( + 'does not rebuild the bundles that contain a file ' + + 'when that file is changed, even when hot loading is enabled', + () => { + const bundleFunc = jest.fn(); + bundleFunc + .mockReturnValueOnce( + Promise.resolve({ + getSource: () => 'this is the first source', + getSourceMap: () => {}, + getSourceMapString: () => 'this is the source map', + getEtag: () => () => 'this is an etag', + }) + ) + .mockReturnValue( + Promise.resolve({ + getSource: () => 'this is the rebuilt source', + getSourceMap: () => {}, + getSourceMapString: () => 'this is the source map', + getEtag: () => () => 'this is an etag', + }) + ); + + Bundler.prototype.bundle = bundleFunc; + + server = new Server(options); + server.setHMRFileChangeListener(() => {}); + + requestHandler = server.processRequest.bind(server); + + makeRequest(requestHandler, 'mybundle.bundle?runModule=true') + .done(response => { + expect(response.body).toEqual('this is the first source'); + expect(bundleFunc.mock.calls.length).toBe(1); + }); + + jest.runAllTicks(); + + server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + jest.runAllTimers(); + jest.runAllTicks(); + + expect(bundleFunc.mock.calls.length).toBe(1); + server.setHMRFileChangeListener(null); + + makeRequest(requestHandler, 'mybundle.bundle?runModule=true') + .done(response => { + expect(response.body).toEqual('this is the rebuilt source'); + expect(bundleFunc.mock.calls.length).toBe(2); + }); + jest.runAllTicks(); + }); + }); + + describe('/onchange endpoint', () => { + let EventEmitter; + let req; + let res; + + beforeEach(() => { + EventEmitter = require.requireActual('events').EventEmitter; + req = scaffoldReq(new EventEmitter()); + req.url = '/onchange'; + res = { + writeHead: jest.fn(), + end: jest.fn() + }; + }); + + it('should hold on to request and inform on change', () => { + server.processRequest(req, res); + server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + jest.runAllTimers(); + expect(res.end).toBeCalledWith(JSON.stringify({changed: true})); + }); + + it('should not inform changes on disconnected clients', () => { + server.processRequest(req, res); + req.emit('close'); + jest.runAllTimers(); + server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + jest.runAllTimers(); + expect(res.end).not.toBeCalled(); + }); + }); + + describe('/assets endpoint', () => { + it('should serve simple case', () => { + const req = scaffoldReq({url: '/assets/imgs/a.png'}); + const res = {end: jest.fn(), setHeader: jest.fn()}; + + AssetServer.prototype.get.mockImplementation(() => Promise.resolve('i am image')); + + server.processRequest(req, res); + jest.runAllTimers(); + expect(res.end).toBeCalledWith('i am image'); + }); + + it('should parse the platform option', () => { + const req = scaffoldReq({url: '/assets/imgs/a.png?platform=ios'}); + const res = {end: jest.fn(), setHeader: jest.fn()}; + + AssetServer.prototype.get.mockImplementation(() => Promise.resolve('i am image')); + + server.processRequest(req, res); + jest.runAllTimers(); + expect(AssetServer.prototype.get).toBeCalledWith('imgs/a.png', 'ios'); + expect(res.end).toBeCalledWith('i am image'); + }); + + it('should serve range request', () => { + const req = scaffoldReq({ + url: '/assets/imgs/a.png?platform=ios', + headers: {range: 'bytes=0-3'}, + }); + const res = {end: jest.fn(), writeHead: jest.fn(), setHeader: jest.fn()}; + const mockData = 'i am image'; + + AssetServer.prototype.get.mockImplementation(() => Promise.resolve(mockData)); + + server.processRequest(req, res); + jest.runAllTimers(); + expect(AssetServer.prototype.get).toBeCalledWith('imgs/a.png', 'ios'); + expect(res.end).toBeCalledWith(mockData.slice(0, 4)); + }); + + it('should serve assets files\'s name contain non-latin letter', () => { + const req = scaffoldReq({url: '/assets/imgs/%E4%B8%BB%E9%A1%B5/logo.png'}); + const res = {end: jest.fn(), setHeader: jest.fn()}; + + AssetServer.prototype.get.mockImplementation(() => Promise.resolve('i am image')); + + server.processRequest(req, res); + jest.runAllTimers(); + expect(AssetServer.prototype.get).toBeCalledWith( + 'imgs/\u{4E3B}\u{9875}/logo.png', + undefined + ); + expect(res.end).toBeCalledWith('i am image'); + }); + }); + + describe('buildbundle(options)', () => { + it('Calls the bundler with the correct args', () => { + return server.buildBundle({ + entryFile: 'foo file' + }).then(() => + expect(Bundler.prototype.bundle).toBeCalledWith({ + entryFile: 'foo file', + inlineSourceMap: false, + minify: false, + hot: false, + runModule: true, + dev: true, + platform: undefined, + runBeforeMainModule: ['InitializeCore'], + unbundle: false, + entryModuleOnly: false, + isolateModuleIDs: false, + assetPlugins: [], + }) + ); + }); + }); + + describe('buildBundleFromUrl(options)', () => { + it('Calls the bundler with the correct args', () => { + return server.buildBundleFromUrl('/path/to/foo.bundle?dev=false&runModule=false') + .then(() => + expect(Bundler.prototype.bundle).toBeCalledWith({ + entryFile: 'path/to/foo.js', + inlineSourceMap: false, + minify: false, + generateSourceMaps: true, + hot: false, + runModule: false, + sourceMapUrl: '/path/to/foo.map?dev=false&runModule=false', + dev: false, + platform: undefined, + runBeforeMainModule: ['InitializeCore'], + unbundle: false, + entryModuleOnly: false, + isolateModuleIDs: false, + assetPlugins: [], + }) + ); + }); + }); + + describe('/symbolicate endpoint', () => { + it('should symbolicate given stack trace', () => { + const body = JSON.stringify({stack: [{ + file: 'http://foo.bundle?platform=ios', + lineNumber: 2100, + column: 44, + customPropShouldBeLeftUnchanged: 'foo', + }]}); + + SourceMapConsumer.prototype.originalPositionFor = jest.fn((frame) => { + expect(frame.line).toEqual(2100); + expect(frame.column).toEqual(44); + return { + source: 'foo.js', + line: 21, + column: 4, + }; + }); + + return makeRequest( + requestHandler, + '/symbolicate', + { rawBody: body } + ).then(response => { + expect(JSON.parse(response.body)).toEqual({ + stack: [{ + file: 'foo.js', + lineNumber: 21, + column: 4, + customPropShouldBeLeftUnchanged: 'foo', + }] + }); + }); + }); + + it('ignores `/debuggerWorker.js` stack frames', () => { + const body = JSON.stringify({stack: [{ + file: 'http://localhost:8081/debuggerWorker.js', + lineNumber: 123, + column: 456, + }]}); + + return makeRequest( + requestHandler, + '/symbolicate', + { rawBody: body } + ).then(response => { + expect(JSON.parse(response.body)).toEqual({ + stack: [{ + file: 'http://localhost:8081/debuggerWorker.js', + lineNumber: 123, + column: 456, + }] + }); + }); + }); + }); + + describe('/symbolicate handles errors', () => { + it('should symbolicate given stack trace', () => { + const body = 'clearly-not-json'; + console.error = jest.fn(); + + return makeRequest( + requestHandler, + '/symbolicate', + { rawBody: body } + ).then(response => { + expect(response.statusCode).toEqual(500); + expect(JSON.parse(response.body)).toEqual({ + error: jasmine.any(String), + }); + expect(console.error).toBeCalled(); + }); + }); + }); + + // ensures that vital properties exist on fake request objects + function scaffoldReq(req) { + if (!req.headers) { + req.headers = {}; + } + return req; + } +}); diff --git a/packages/metro-bundler/react-packager/src/Server/index.js b/packages/metro-bundler/react-packager/src/Server/index.js new file mode 100644 index 00000000..811b0357 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/Server/index.js @@ -0,0 +1,972 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const AssetServer = require('../AssetServer'); +const getPlatformExtension = require('../node-haste').getPlatformExtension; +const Bundler = require('../Bundler'); +const MultipartResponse = require('./MultipartResponse'); +const SourceMapConsumer = require('source-map').SourceMapConsumer; + +const declareOpts = require('../lib/declareOpts'); +const defaults = require('../../../defaults'); +const mime = require('mime-types'); +const path = require('path'); +const terminal = require('../lib/terminal'); +const url = require('url'); + +const debug = require('debug')('RNP:Server'); + +import type Module from '../node-haste/Module'; +import type {Stats} from 'fs'; +import type {IncomingMessage, ServerResponse} from 'http'; +import type ResolutionResponse from '../node-haste/DependencyGraph/ResolutionResponse'; +import type Bundle from '../Bundler/Bundle'; +import type {Reporter} from '../lib/reporting'; +import type {GetTransformOptions} from '../Bundler'; +import type GlobalTransformCache from '../lib/GlobalTransformCache'; + +const { + createActionStartEntry, + createActionEndEntry, + log, +} = require('../Logger'); + +function debounceAndBatch(fn, delay) { + let timeout, args = []; + return (value) => { + args.push(value); + clearTimeout(timeout); + timeout = setTimeout(() => { + const a = args; + args = []; + fn(a); + }, delay); + }; +} + +type Options = { + assetExts?: Array, + blacklistRE?: RegExp, + cacheVersion?: string, + extraNodeModules?: {}, + getTransformOptions?: GetTransformOptions, + globalTransformCache: ?GlobalTransformCache, + moduleFormat?: string, + platforms?: Array, + polyfillModuleNames?: Array, + projectRoots: Array, + providesModuleNodeModules?: Array, + reporter: Reporter, + resetCache?: boolean, + silent?: boolean, + transformModulePath?: string, + transformTimeoutInterval?: number, + watch?: boolean, +}; + +const bundleOpts = declareOpts({ + sourceMapUrl: { + type: 'string', + required: false, + }, + entryFile: { + type: 'string', + required: true, + }, + dev: { + type: 'boolean', + default: true, + }, + minify: { + type: 'boolean', + default: false, + }, + runModule: { + type: 'boolean', + default: true, + }, + inlineSourceMap: { + type: 'boolean', + default: false, + }, + platform: { + type: 'string', + required: true, + }, + runBeforeMainModule: { + type: 'array', + default: defaults.runBeforeMainModule, + }, + unbundle: { + type: 'boolean', + default: false, + }, + hot: { + type: 'boolean', + default: false, + }, + entryModuleOnly: { + type: 'boolean', + default: false, + }, + isolateModuleIDs: { + type: 'boolean', + default: false, + }, + resolutionResponse: { + type: 'object', + }, + generateSourceMaps: { + type: 'boolean', + required: false, + }, + assetPlugins: { + type: 'array', + default: [], + }, + onProgress: { + type: 'function', + }, +}); + +const dependencyOpts = declareOpts({ + platform: { + type: 'string', + required: true, + }, + dev: { + type: 'boolean', + default: true, + }, + entryFile: { + type: 'string', + required: true, + }, + recursive: { + type: 'boolean', + default: true, + }, + hot: { + type: 'boolean', + default: false, + }, + minify: { + type: 'boolean', + default: undefined, + }, +}); + +const bundleDeps = new WeakMap(); +const NODE_MODULES = `${path.sep}node_modules${path.sep}`; + +class Server { + + _opts: { + assetExts: Array, + blacklistRE: ?RegExp, + cacheVersion: string, + extraNodeModules: {}, + getTransformOptions?: GetTransformOptions, + moduleFormat: string, + platforms: Array, + polyfillModuleNames: Array, + projectRoots: Array, + providesModuleNodeModules?: Array, + reporter: Reporter, + resetCache: boolean, + silent: boolean, + transformModulePath: ?string, + transformTimeoutInterval: ?number, + watch: boolean, + }; + _projectRoots: Array; + _bundles: {}; + _changeWatchers: Array<{ + req: IncomingMessage, + res: ServerResponse, + }>; + _fileChangeListeners: Array<(filePath: string) => mixed>; + _assetServer: AssetServer; + _bundler: Bundler; + _debouncedFileChangeHandler: (filePath: string) => mixed; + _hmrFileChangeListener: (type: string, filePath: string) => mixed; + _reporter: Reporter; + + constructor(options: Options) { + this._opts = { + assetExts: options.assetExts || defaults.assetExts, + blacklistRE: options.blacklistRE, + cacheVersion: options.cacheVersion || '1.0', + extraNodeModules: options.extraNodeModules || {}, + getTransformOptions: options.getTransformOptions, + globalTransformCache: options.globalTransformCache, + moduleFormat: options.moduleFormat != null ? options.moduleFormat : 'haste', + platforms: options.platforms || defaults.platforms, + polyfillModuleNames: options.polyfillModuleNames || [], + projectRoots: options.projectRoots, + providesModuleNodeModules: options.providesModuleNodeModules, + reporter: options.reporter, + resetCache: options.resetCache || false, + silent: options.silent || false, + transformModulePath: options.transformModulePath, + transformTimeoutInterval: options.transformTimeoutInterval, + watch: options.watch || false, + }; + const processFileChange = + ({type, filePath, stat}) => this.onFileChange(type, filePath, stat); + + this._reporter = options.reporter; + this._projectRoots = this._opts.projectRoots; + this._bundles = Object.create(null); + this._changeWatchers = []; + this._fileChangeListeners = []; + + this._assetServer = new AssetServer({ + assetExts: this._opts.assetExts, + projectRoots: this._opts.projectRoots, + }); + + const bundlerOpts = Object.create(this._opts); + bundlerOpts.assetServer = this._assetServer; + bundlerOpts.allowBundleUpdates = this._opts.watch; + bundlerOpts.globalTransformCache = options.globalTransformCache; + bundlerOpts.watch = this._opts.watch; + bundlerOpts.reporter = options.reporter; + this._bundler = new Bundler(bundlerOpts); + + // changes to the haste map can affect resolution of files in the bundle + const dependencyGraph = this._bundler.getResolver().getDependencyGraph(); + dependencyGraph.load().then(() => { + dependencyGraph.getWatcher().on( + 'change', + ({eventsQueue}) => eventsQueue.forEach(processFileChange), + ); + dependencyGraph.getHasteMap().on('change', () => { + debug('Clearing bundle cache due to haste map change'); + this._clearBundles(); + }); + }); + + this._debouncedFileChangeHandler = debounceAndBatch(filePaths => { + // only clear bundles for non-JS changes + if (filePaths.every(RegExp.prototype.test, /\.js(?:on)?$/i)) { + for (const key in this._bundles) { + this._bundles[key].then(bundle => { + const deps = bundleDeps.get(bundle); + filePaths.forEach(filePath => { + // $FlowFixMe(>=0.37.0) + if (deps.files.has(filePath)) { + // $FlowFixMe(>=0.37.0) + deps.outdated.add(filePath); + } + }); + }).catch(e => { + debug(`Could not update bundle: ${e}, evicting from cache`); + delete this._bundles[key]; + }); + } + } else { + debug('Clearing bundles due to non-JS change'); + this._clearBundles(); + } + this._informChangeWatchers(); + }, 50); + } + + end(): mixed { + return this._bundler.end(); + } + + setHMRFileChangeListener( + listener: (type: string, filePath: string) => mixed, + ) { + this._hmrFileChangeListener = listener; + } + + addFileChangeListener(listener: (filePath: string) => mixed) { + if (this._fileChangeListeners.indexOf(listener) === -1) { + this._fileChangeListeners.push(listener); + } + } + + buildBundle(options: { + entryFile: string, + platform?: string, + }): Promise { + return this._bundler.getResolver().getDependencyGraph().load().then(() => { + if (!options.platform) { + options.platform = getPlatformExtension(options.entryFile); + } + + const opts = bundleOpts(options); + const building = this._bundler.bundle(opts); + building.then(bundle => { + const modules = bundle.getModules(); + const nonVirtual = modules.filter(m => !m.virtual); + bundleDeps.set(bundle, { + files: new Map( + nonVirtual + .map(({sourcePath, meta = {dependencies: []}}) => + [sourcePath, meta.dependencies]) + ), + idToIndex: new Map(modules.map(({id}, i) => [id, i])), + dependencyPairs: new Map( + nonVirtual + .filter(({meta}) => meta && meta.dependencyPairs) + .map(m => [m.sourcePath, m.meta.dependencyPairs]) + ), + outdated: new Set(), + }); + }); + return building; + }); + } + + buildBundleFromUrl(reqUrl: string): Promise { + const options = this._getOptionsFromUrl(reqUrl); + return this.buildBundle(options); + } + + buildBundleForHMR( + options: {platform: ?string}, + host: string, + port: number, + ): Promise { + return this._bundler.hmrBundle(options, host, port); + } + + getShallowDependencies(options: { + entryFile: string, + platform?: string, + }): Promise { + return Promise.resolve().then(() => { + if (!options.platform) { + options.platform = getPlatformExtension(options.entryFile); + } + + const opts = dependencyOpts(options); + return this._bundler.getShallowDependencies(opts); + }); + } + + getModuleForPath(entryFile: string): Module { + return this._bundler.getModuleForPath(entryFile); + } + + getDependencies(options: { + entryFile: string, + platform?: string, + }): Promise { + return Promise.resolve().then(() => { + if (!options.platform) { + options.platform = getPlatformExtension(options.entryFile); + } + + const opts = dependencyOpts(options); + return this._bundler.getDependencies(opts); + }); + } + + getOrderedDependencyPaths(options: {}): Promise { + return Promise.resolve().then(() => { + const opts = dependencyOpts(options); + return this._bundler.getOrderedDependencyPaths(opts); + }); + } + + onFileChange(type: string, filePath: string, stat: Stats) { + this._assetServer.onFileChange(type, filePath, stat); + this._bundler.invalidateFile(filePath); + + // If Hot Loading is enabled avoid rebuilding bundles and sending live + // updates. Instead, send the HMR updates right away and clear the bundles + // cache so that if the user reloads we send them a fresh bundle + if (this._hmrFileChangeListener) { + // Clear cached bundles in case user reloads + this._clearBundles(); + this._hmrFileChangeListener(type, filePath); + return; + } else if (type !== 'change' && filePath.indexOf(NODE_MODULES) !== -1) { + // node module resolution can be affected by added or removed files + debug('Clearing bundles due to potential node_modules resolution change'); + this._clearBundles(); + } + + Promise.all( + this._fileChangeListeners.map(listener => listener(filePath)) + ).then( + () => this._onFileChangeComplete(filePath), + () => this._onFileChangeComplete(filePath) + ); + } + + _onFileChangeComplete(filePath: string) { + // Make sure the file watcher event runs through the system before + // we rebuild the bundles. + this._debouncedFileChangeHandler(filePath); + } + + _clearBundles() { + this._bundles = Object.create(null); + } + + _informChangeWatchers() { + const watchers = this._changeWatchers; + const headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + + watchers.forEach(function(w) { + w.res.writeHead(205, headers); + w.res.end(JSON.stringify({ changed: true })); + }); + + this._changeWatchers = []; + } + + _processDebugRequest(reqUrl: string, res: ServerResponse) { + let ret = ''; + const pathname = url.parse(reqUrl).pathname; + /* $FlowFixMe: pathname would be null for an invalid URL */ + const parts = pathname.split('/').filter(Boolean); + if (parts.length === 1) { + ret += ''; + res.end(ret); + } else if (parts[1] === 'bundles') { + ret += '

Cached Bundles

'; + Promise.all(Object.keys(this._bundles).map(optionsJson => + this._bundles[optionsJson].then(p => { + ret += '

' + optionsJson + '

'; + ret += p.getDebugInfo(); + }) + )).then( + () => res.end(ret), + e => { + res.writeHead(500); + res.end('Internal Error'); + terminal.log(e.stack); // eslint-disable-line no-console-disallow + } + ); + } else { + res.writeHead(404); + res.end('Invalid debug request'); + return; + } + } + + _processOnChangeRequest(req: IncomingMessage, res: ServerResponse) { + const watchers = this._changeWatchers; + + watchers.push({ + req: req, + res: res, + }); + + req.on('close', () => { + for (let i = 0; i < watchers.length; i++) { + if (watchers[i] && watchers[i].req === req) { + watchers.splice(i, 1); + break; + } + } + }); + } + + _rangeRequestMiddleware( + req: IncomingMessage, + res: ServerResponse, + data: string, + assetPath: string, + ) { + if (req.headers && req.headers.range) { + const [rangeStart, rangeEnd] = req.headers.range.replace(/bytes=/, '').split('-'); + const dataStart = parseInt(rangeStart, 10); + const dataEnd = rangeEnd ? parseInt(rangeEnd, 10) : data.length - 1; + const chunksize = (dataEnd - dataStart) + 1; + + res.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Range': `bytes ${dataStart}-${dataEnd}/${data.length}`, + 'Content-Type': mime.lookup(path.basename(assetPath[1])), + }); + + return data.slice(dataStart, dataEnd + 1); + } + + return data; + } + + _processAssetsRequest(req: IncomingMessage, res: ServerResponse) { + const urlObj = url.parse(decodeURI(req.url), true); + /* $FlowFixMe: could be empty if the url is invalid */ + const assetPath: string = urlObj.pathname.match(/^\/assets\/(.+)$/); + + const processingAssetRequestLogEntry = + log(createActionStartEntry({ + action_name: 'Processing asset request', + asset: assetPath[1], + })); + + /* $FlowFixMe: query may be empty for invalid URLs */ + this._assetServer.get(assetPath[1], urlObj.query.platform) + .then( + data => { + // Tell clients to cache this for 1 year. + // This is safe as the asset url contains a hash of the asset. + if (process.env.REACT_NATIVE_ENABLE_ASSET_CACHING === true) { + res.setHeader('Cache-Control', 'max-age=31536000'); + } + res.end(this._rangeRequestMiddleware(req, res, data, assetPath)); + process.nextTick(() => { + log(createActionEndEntry(processingAssetRequestLogEntry)); + }); + }, + error => { + console.error(error.stack); + res.writeHead(404); + res.end('Asset not found'); + } + ); + } + + optionsHash(options: {}) { + // onProgress is a function, can't be serialized + return JSON.stringify(Object.assign({}, options, { onProgress: null })); + } + + _useCachedOrUpdateOrCreateBundle(options: { + entryFile: string, + platform?: string, + }): Promise { + const optionsJson = this.optionsHash(options); + const bundleFromScratch = () => { + const building = this.buildBundle(options); + this._bundles[optionsJson] = building; + return building; + }; + + if (optionsJson in this._bundles) { + return this._bundles[optionsJson].then(bundle => { + const deps = bundleDeps.get(bundle); + // $FlowFixMe(>=0.37.0) + const {dependencyPairs, files, idToIndex, outdated} = deps; + if (outdated.size) { + + const updatingExistingBundleLogEntry = + log(createActionStartEntry({ + action_name: 'Updating existing bundle', + outdated_modules: outdated.size, + })); + this._reporter.update({ + type: 'bundle_update_existing', + entryFilePath: options.entryFile, + outdatedModuleCount: outdated.size, + }); + + debug('Attempt to update existing bundle'); + + const changedModules = + Array.from(outdated, this.getModuleForPath, this); + // $FlowFixMe(>=0.37.0) + deps.outdated = new Set(); + + const opts = bundleOpts(options); + const {platform, dev, minify, hot} = opts; + + // Need to create a resolution response to pass to the bundler + // to process requires after transform. By providing a + // specific response we can compute a non recursive one which + // is the least we need and improve performance. + const bundlePromise = this._bundles[optionsJson] = + this.getDependencies({ + platform, dev, hot, minify, + entryFile: options.entryFile, + recursive: false, + }).then(response => { + debug('Update bundle: rebuild shallow bundle'); + + changedModules.forEach(m => { + response.setResolvedDependencyPairs( + m, + dependencyPairs.get(m.path), + {ignoreFinalized: true}, + ); + }); + + return this.buildBundle({ + ...options, + resolutionResponse: response.copy({ + dependencies: changedModules, + }), + }).then(updateBundle => { + const oldModules = bundle.getModules(); + const newModules = updateBundle.getModules(); + for (let i = 0, n = newModules.length; i < n; i++) { + const moduleTransport = newModules[i]; + const {meta, sourcePath} = moduleTransport; + if (outdated.has(sourcePath)) { + /* $FlowFixMe: `meta` could be empty */ + if (!contentsEqual(meta.dependencies, new Set(files.get(sourcePath)))) { + // bail out if any dependencies changed + return Promise.reject(Error( + `Dependencies of ${sourcePath} changed from [${ + /* $FlowFixMe: `get` can return empty */ + files.get(sourcePath).join(', ') + }] to [${ + /* $FlowFixMe: `meta` could be empty */ + meta.dependencies.join(', ') + }]` + )); + } + + oldModules[idToIndex.get(moduleTransport.id)] = moduleTransport; + } + } + + bundle.invalidateSource(); + + log(createActionEndEntry(updatingExistingBundleLogEntry)); + + debug('Successfully updated existing bundle'); + return bundle; + }); + }).catch(e => { + debug('Failed to update existing bundle, rebuilding...', e.stack || e.message); + return bundleFromScratch(); + }); + return bundlePromise; + } else { + debug('Using cached bundle'); + return bundle; + } + }); + } + + return bundleFromScratch(); + } + + processRequest( + req: IncomingMessage, + res: ServerResponse, + next: () => mixed, + ) { + const urlObj = url.parse(req.url, true); + const {host} = req.headers; + debug(`Handling request: ${host ? 'http://' + host : ''}${req.url}`); + /* $FlowFixMe: Could be empty if the URL is invalid. */ + const pathname: string = urlObj.pathname; + + let requestType; + if (pathname.match(/\.bundle$/)) { + requestType = 'bundle'; + } else if (pathname.match(/\.map$/)) { + requestType = 'map'; + } else if (pathname.match(/\.assets$/)) { + requestType = 'assets'; + } else if (pathname.match(/^\/debug/)) { + this._processDebugRequest(req.url, res); + return; + } else if (pathname.match(/^\/onchange\/?$/)) { + this._processOnChangeRequest(req, res); + return; + } else if (pathname.match(/^\/assets\//)) { + this._processAssetsRequest(req, res); + return; + } else if (pathname === '/symbolicate') { + this._symbolicate(req, res); + return; + } else { + next(); + return; + } + + const options = this._getOptionsFromUrl(req.url); + this._reporter.update({ + type: 'bundle_requested', + entryFilePath: options.entryFile, + }); + const requestingBundleLogEntry = + log(createActionStartEntry({ + action_name: 'Requesting bundle', + bundle_url: req.url, + entry_point: options.entryFile, + })); + + let reportProgress = () => {}; + if (!this._opts.silent) { + reportProgress = (transformedFileCount, totalFileCount) => { + this._reporter.update({ + type: 'bundle_transform_progressed', + entryFilePath: options.entryFile, + transformedFileCount, + totalFileCount, + }); + }; + } + + const mres = MultipartResponse.wrap(req, res); + options.onProgress = (done, total) => { + reportProgress(done, total); + mres.writeChunk({'Content-Type': 'application/json'}, JSON.stringify({done, total})); + }; + + debug('Getting bundle for request'); + const building = this._useCachedOrUpdateOrCreateBundle(options); + building.then( + p => { + this._reporter.update({ + type: 'bundle_built', + entryFilePath: options.entryFile, + }); + if (requestType === 'bundle') { + debug('Generating source code'); + const bundleSource = p.getSource({ + inlineSourceMap: options.inlineSourceMap, + minify: options.minify, + dev: options.dev, + }); + debug('Writing response headers'); + const etag = p.getEtag(); + mres.setHeader('Content-Type', 'application/javascript'); + mres.setHeader('ETag', etag); + + if (req.headers['if-none-match'] === etag) { + debug('Responding with 304'); + mres.writeHead(304); + mres.end(); + } else { + mres.end(bundleSource); + } + debug('Finished response'); + log(createActionEndEntry(requestingBundleLogEntry)); + } else if (requestType === 'map') { + const sourceMap = p.getSourceMapString({ + minify: options.minify, + dev: options.dev, + }); + + mres.setHeader('Content-Type', 'application/json'); + mres.end(sourceMap); + log(createActionEndEntry(requestingBundleLogEntry)); + } else if (requestType === 'assets') { + const assetsList = JSON.stringify(p.getAssets()); + mres.setHeader('Content-Type', 'application/json'); + mres.end(assetsList); + log(createActionEndEntry(requestingBundleLogEntry)); + } + }, + error => this._handleError(mres, this.optionsHash(options), error) + ).catch(error => { + process.nextTick(() => { + throw error; + }); + }); + } + + _symbolicate(req: IncomingMessage, res: ServerResponse) { + const symbolicatingLogEntry = + log(createActionStartEntry('Symbolicating')); + + /* $FlowFixMe: where is `rowBody` defined? Is it added by + * the `connect` framework? */ + Promise.resolve(req.rawBody).then(body => { + const stack = JSON.parse(body).stack; + + // In case of multiple bundles / HMR, some stack frames can have + // different URLs from others + const urlIndexes = {}; + const uniqueUrls = []; + stack.forEach(frame => { + const sourceUrl = frame.file; + // Skip `/debuggerWorker.js` which drives remote debugging because it + // does not need to symbolication. + // Skip anything except http(s), because there is no support for that yet + if (!urlIndexes.hasOwnProperty(sourceUrl) && + !sourceUrl.endsWith('/debuggerWorker.js') && + sourceUrl.startsWith('http')) { + urlIndexes[sourceUrl] = uniqueUrls.length; + uniqueUrls.push(sourceUrl); + } + }); + + const sourceMaps = uniqueUrls.map( + sourceUrl => this._sourceMapForURL(sourceUrl) + ); + return Promise.all(sourceMaps).then(consumers => { + return stack.map(frame => { + const sourceUrl = frame.file; + if (!urlIndexes.hasOwnProperty(sourceUrl)) { + return frame; + } + const idx = urlIndexes[sourceUrl]; + const consumer = consumers[idx]; + const original = consumer.originalPositionFor({ + line: frame.lineNumber, + column: frame.column, + }); + if (!original) { + return frame; + } + return Object.assign({}, frame, { + file: original.source, + lineNumber: original.line, + column: original.column, + }); + }); + }); + }).then( + stack => { + res.end(JSON.stringify({stack: stack})); + process.nextTick(() => { + log(createActionEndEntry(symbolicatingLogEntry)); + }); + }, + error => { + console.error(error.stack || error); + res.statusCode = 500; + res.end(JSON.stringify({error: error.message})); + } + ); + } + + _sourceMapForURL(reqUrl: string): Promise { + const options = this._getOptionsFromUrl(reqUrl); + const building = this._useCachedOrUpdateOrCreateBundle(options); + return building.then(p => { + const sourceMap = p.getSourceMap({ + minify: options.minify, + dev: options.dev, + }); + return new SourceMapConsumer(sourceMap); + }); + } + + _handleError(res: ServerResponse, bundleID: string, error: { + status: number, + type: string, + description: string, + filename: string, + lineNumber: number, + errors: Array<{description: string, filename: string, lineNumber: number}>, + }) { + res.writeHead(error.status || 500, { + 'Content-Type': 'application/json; charset=UTF-8', + }); + + if (error.type === 'TransformError' || + error.type === 'NotFoundError' || + error.type === 'UnableToResolveError') { + error.errors = [{ + description: error.description, + filename: error.filename, + lineNumber: error.lineNumber, + }]; + res.end(JSON.stringify(error)); + + if (error.type === 'NotFoundError') { + delete this._bundles[bundleID]; + } + } else { + console.error(error.stack || error); + res.end(JSON.stringify({ + type: 'InternalError', + message: 'react-packager has encountered an internal error, ' + + 'please check your terminal error output for more details', + })); + } + } + + _getOptionsFromUrl(reqUrl: string): { + sourceMapUrl: string, + entryFile: string, + dev: boolean, + minify: boolean, + hot: boolean, + runModule: boolean, + inlineSourceMap: boolean, + platform?: string, + entryModuleOnly: boolean, + generateSourceMaps: boolean, + assetPlugins: Array, + onProgress?: (doneCont: number, totalCount: number) => mixed, + } { + // `true` to parse the query param as an object. + const urlObj = url.parse(reqUrl, true); + /* $FlowFixMe: `pathname` could be empty for an invalid URL */ + const pathname = decodeURIComponent(urlObj.pathname); + + // Backwards compatibility. Options used to be as added as '.' to the + // entry module name. We can safely remove these options. + const entryFile = pathname.replace(/^\//, '').split('.').filter(part => { + if (part === 'includeRequire' || part === 'runModule' || + part === 'bundle' || part === 'map' || part === 'assets') { + return false; + } + return true; + }).join('.') + '.js'; + + const sourceMapUrlObj = Object.assign({}, urlObj); + sourceMapUrlObj.pathname = pathname.replace(/\.bundle$/, '.map'); + + // try to get the platform from the url + /* $FlowFixMe: `query` could be empty for an invalid URL */ + const platform = urlObj.query.platform || + getPlatformExtension(pathname); + + /* $FlowFixMe: `query` could be empty for an invalid URL */ + const assetPlugin = urlObj.query.assetPlugin; + const assetPlugins = Array.isArray(assetPlugin) ? + assetPlugin : + (typeof assetPlugin === 'string') ? [assetPlugin] : []; + + const dev = this._getBoolOptionFromQuery(urlObj.query, 'dev', true); + const minify = this._getBoolOptionFromQuery(urlObj.query, 'minify', false); + return { + sourceMapUrl: url.format(sourceMapUrlObj), + entryFile: entryFile, + dev, + minify, + hot: this._getBoolOptionFromQuery(urlObj.query, 'hot', false), + runModule: this._getBoolOptionFromQuery(urlObj.query, 'runModule', true), + inlineSourceMap: this._getBoolOptionFromQuery( + urlObj.query, + 'inlineSourceMap', + false + ), + platform: platform, + entryModuleOnly: this._getBoolOptionFromQuery( + urlObj.query, + 'entryModuleOnly', + false, + ), + generateSourceMaps: minify || !dev || this._getBoolOptionFromQuery(urlObj.query, 'babelSourcemap', false), + assetPlugins, + }; + } + + _getBoolOptionFromQuery(query: ?{}, opt: string, defaultVal: boolean): boolean { + /* $FlowFixMe: `query` could be empty when it comes from an invalid URL */ + if (query[opt] == null) { + return defaultVal; + } + + return query[opt] === 'true' || query[opt] === '1'; + } +} + +function contentsEqual(array: Array, set: Set): boolean { + return array.length === set.size && array.every(set.has, set); +} + +module.exports = Server; diff --git a/packages/metro-bundler/react-packager/src/__mocks__/debug.js b/packages/metro-bundler/react-packager/src/__mocks__/debug.js new file mode 100644 index 00000000..87390917 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/__mocks__/debug.js @@ -0,0 +1,11 @@ +/** + * 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'; + +module.exports = () => () => {}; diff --git a/packages/metro-bundler/react-packager/src/lib/BatchProcessor.js b/packages/metro-bundler/react-packager/src/lib/BatchProcessor.js new file mode 100644 index 00000000..0464534d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/BatchProcessor.js @@ -0,0 +1,104 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const invariant = require('fbjs/lib/invariant'); + +type ProcessBatch = ( + batch: Array, + callback: (error?: Error, orderedResults?: Array) => mixed, +) => mixed; + +type BatchProcessorOptions = { + maximumDelayMs: number, + maximumItems: number, + concurrency: number, +}; + +/** + * We batch items together trying to minimize their processing, for example as + * network queries. For that we wait a small moment before processing a batch. + * We limit also the number of items we try to process in a single batch so that + * if we have many items pending in a short amount of time, we can start + * processing right away. + */ +class BatchProcessor { + + _options: BatchProcessorOptions; + _processBatch: ProcessBatch; + _queue: Array<{ + item: TItem, + callback: (error?: Error, result?: TResult) => mixed, + }>; + _timeoutHandle: ?number; + _currentProcessCount: number; + + constructor( + options: BatchProcessorOptions, + processBatch: ProcessBatch, + ) { + this._options = options; + this._processBatch = processBatch; + this._queue = []; + this._timeoutHandle = null; + this._currentProcessCount = 0; + (this: any)._processQueue = this._processQueue.bind(this); + } + + _processQueue() { + this._timeoutHandle = null; + while ( + this._queue.length > 0 && + this._currentProcessCount < this._options.concurrency + ) { + this._currentProcessCount++; + const jobs = this._queue.splice(0, this._options.maximumItems); + const items = jobs.map(job => job.item); + this._processBatch(items, (error, results) => { + invariant( + results == null || results.length === items.length, + 'Not enough results returned.', + ); + for (let i = 0; i < items.length; ++i) { + jobs[i].callback(error, results && results[i]); + } + this._currentProcessCount--; + this._processQueueOnceReady(); + }); + } + } + + _processQueueOnceReady() { + if (this._queue.length >= this._options.maximumItems) { + clearTimeout(this._timeoutHandle); + process.nextTick(this._processQueue); + return; + } + if (this._timeoutHandle == null) { + this._timeoutHandle = setTimeout( + this._processQueue, + this._options.maximumDelayMs, + ); + } + } + + queue( + item: TItem, + callback: (error?: Error, result?: TResult) => mixed, + ) { + this._queue.push({item, callback}); + this._processQueueOnceReady(); + } + +} + +module.exports = BatchProcessor; diff --git a/packages/metro-bundler/react-packager/src/lib/GlobalTransformCache.js b/packages/metro-bundler/react-packager/src/lib/GlobalTransformCache.js new file mode 100644 index 00000000..5775a36d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/GlobalTransformCache.js @@ -0,0 +1,310 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const BatchProcessor = require('./BatchProcessor'); + +const crypto = require('crypto'); +const imurmurhash = require('imurmurhash'); +const jsonStableStringify = require('json-stable-stringify'); +const path = require('path'); +const request = require('request'); + +import type {Options as TransformOptions} from '../JSTransformer/worker/worker'; +import type {CachedResult} from './TransformCache'; +import type {Reporter} from './reporting'; + +type FetchResultURIs = ( + keys: Array, + callback: (error?: Error, results?: Map) => void, +) => mixed; + +type StoreResults = ( + resultsByKey: Map, + callback: (error?: Error) => void, +) => mixed; + +type FetchProps = { + filePath: string, + sourceCode: string, + transformCacheKey: string, + transformOptions: TransformOptions, +}; + +type FetchCallback = (error?: Error, result?: ?CachedResult) => mixed; +type FetchURICallback = (error?: Error, resultURI?: ?string) => mixed; + +type URI = string; + +/** + * We aggregate the requests to do a single request for many keys. It also + * ensures we do a single request at a time to avoid pressuring the I/O. + */ +class KeyURIFetcher { + + _batchProcessor: BatchProcessor; + _fetchResultURIs: FetchResultURIs; + _processError: (error: Error) => mixed; + + /** + * When a batch request fails for some reason, we process the error locally + * and we proceed as if there were no result for these keys instead. That way + * a build will not fail just because of the cache. + */ + _processKeys( + keys: Array, + callback: (error?: Error, keyURIs: Array) => mixed, + ) { + this._fetchResultURIs(keys, (error, URIsByKey) => { + if (error != null) { + this._processError(error); + } + const URIs = keys.map(key => URIsByKey && URIsByKey.get(key)); + callback(undefined, URIs); + }); + } + + fetch(key: string, callback: FetchURICallback) { + this._batchProcessor.queue(key, callback); + } + + constructor(fetchResultURIs: FetchResultURIs, processError: (error: Error) => mixed) { + this._fetchResultURIs = fetchResultURIs; + this._batchProcessor = new BatchProcessor({ + maximumDelayMs: 10, + maximumItems: 500, + concurrency: 25, + }, this._processKeys.bind(this)); + this._processError = processError; + } + +} + +class KeyResultStore { + + _storeResults: StoreResults; + _batchProcessor: BatchProcessor<{key: string, result: CachedResult}, void>; + + _processResults( + keyResults: Array<{key: string, result: CachedResult}>, + callback: (error?: Error) => mixed, + ) { + const resultsByKey = new Map( + keyResults.map(pair => [pair.key, pair.result]), + ); + this._storeResults(resultsByKey, error => { + callback(error); + }); + } + + store(key: string, result: CachedResult) { + this._batchProcessor.queue({key, result}, () => {}); + } + + constructor(storeResults: StoreResults) { + this._storeResults = storeResults; + this._batchProcessor = new BatchProcessor({ + maximumDelayMs: 1000, + maximumItems: 100, + concurrency: 10, + }, this._processResults.bind(this)); + } + +} + +function validateCachedResult(cachedResult: mixed): ?CachedResult { + if ( + cachedResult != null && + typeof cachedResult === 'object' && + typeof cachedResult.code === 'string' && + Array.isArray(cachedResult.dependencies) && + cachedResult.dependencies.every(dep => typeof dep === 'string') && + Array.isArray(cachedResult.dependencyOffsets) && + cachedResult.dependencyOffsets.every(offset => typeof offset === 'number') + ) { + return (cachedResult: any); + } + return undefined; +} + +/** + * The transform options contain absolute paths. This can contain, for + * example, the username if someone works their home directory (very likely). + * We need to get rid of this user-and-machine-dependent data for the global + * cache, otherwise nobody would share the same cache keys. + */ +function globalizeTransformOptions( + options: TransformOptions, +): TransformOptions { + const {transform} = options; + if (transform == null) { + return options; + } + return { + ...options, + transform: { + ...transform, + projectRoots: transform.projectRoots.map(p => { + return path.relative(path.join(__dirname, '../../../../..'), p); + }), + }, + }; +} + +export type TransformProfile = {+dev: boolean, +minify: boolean, +platform: string}; + +function profileKey({dev, minify, platform}: TransformProfile): string { + return jsonStableStringify({dev, minify, platform}); +} + +/** + * We avoid doing any request to the server if we know the server is not + * going to have any key at all for a particular set of transform options. + */ +class TransformProfileSet { + _profileKeys: Set; + constructor(profiles: Iterable) { + this._profileKeys = new Set(); + for (const profile of profiles) { + this._profileKeys.add(profileKey(profile)); + } + } + has(profile: TransformProfile): boolean { + return this._profileKeys.has(profileKey(profile)); + } +} + +class GlobalTransformCache { + + _fetcher: KeyURIFetcher; + _profileSet: TransformProfileSet; + _reporter: Reporter; + _retries: number; + _store: ?KeyResultStore; + + /** + * If too many errors already happened, we just drop the additional errors. + */ + _processError(error: Error) { + if (this._retries <= 0) { + return; + } + this._reporter.update({type: 'global_cache_error', error}); + --this._retries; + if (this._retries <= 0) { + this._reporter.update({type: 'global_cache_disabled', reason: 'too_many_errors'}); + } + } + + /** + * For using the global cache one needs to have some kind of central key-value + * store that gets prefilled using keyOf() and the transformed results. The + * fetching function should provide a mapping of keys to URIs. The files + * referred by these URIs contains the transform results. Using URIs instead + * of returning the content directly allows for independent and parallel + * fetching of each result, that may be arbitrarily large JSON blobs. + */ + constructor( + fetchResultURIs: FetchResultURIs, + storeResults: ?StoreResults, + profiles: Iterable, + reporter: Reporter, + ) { + this._fetcher = new KeyURIFetcher(fetchResultURIs, this._processError.bind(this)); + this._profileSet = new TransformProfileSet(profiles); + this._reporter = reporter; + this._retries = 4; + if (storeResults != null) { + this._store = new KeyResultStore(storeResults); + } + } + + /** + * Return a key for identifying uniquely a source file. + */ + static keyOf(props: FetchProps) { + const stableOptions = globalizeTransformOptions(props.transformOptions); + const digest = crypto.createHash('sha1').update([ + jsonStableStringify(stableOptions), + props.transformCacheKey, + imurmurhash(props.sourceCode).result().toString(), + ].join('$')).digest('hex'); + return `${digest}-${path.basename(props.filePath)}`; + } + + /** + * We may want to improve that logic to return a stream instead of the whole + * blob of transformed results. However the results are generally only a few + * megabytes each. + */ + _fetchFromURI(uri: string, callback: FetchCallback) { + request.get({uri, json: true, timeout: 4000}, (error, response, unvalidatedResult) => { + if (error != null) { + callback(error); + return; + } + if (response.statusCode !== 200) { + callback(new Error( + `Unexpected HTTP status code: ${response.statusCode}`, + )); + return; + } + const result = validateCachedResult(unvalidatedResult); + if (result == null) { + callback(new Error('Invalid result returned by server.')); + return; + } + callback(undefined, result); + }); + } + + /** + * Wrap `_fetchFromURI` with error logging, and return an empty result instead + * of errors. This is because the global cache is not critical to the normal + * packager operation. + */ + _tryFetchingFromURI(uri: string, callback: FetchCallback) { + this._fetchFromURI(uri, (error, result) => { + if (error != null) { + this._processError(error); + } + callback(undefined, result); + }); + } + + fetch(props: FetchProps, callback: FetchCallback) { + if (this._retries <= 0 || !this._profileSet.has(props.transformOptions)) { + process.nextTick(callback); + return; + } + this._fetcher.fetch(GlobalTransformCache.keyOf(props), (error, uri) => { + if (error != null) { + callback(error); + } else { + if (uri == null) { + callback(); + return; + } + this._tryFetchingFromURI(uri, callback); + } + }); + } + + store(props: FetchProps, result: CachedResult) { + if (this._store != null) { + this._store.store(GlobalTransformCache.keyOf(props), result); + } + } + +} + +module.exports = GlobalTransformCache; diff --git a/packages/metro-bundler/react-packager/src/lib/ModuleTransport.js b/packages/metro-bundler/react-packager/src/lib/ModuleTransport.js new file mode 100644 index 00000000..fefecb63 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/ModuleTransport.js @@ -0,0 +1,77 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +import type {RawMapping} from '../Bundler/source-map'; +import type {MixedSourceMap} from './SourceMap'; + +type SourceMapOrMappings = MixedSourceMap | Array; + +type Metadata = { + dependencyPairs?: Array<[mixed, {path: string}]>, + preloaded?: boolean, +}; + +class ModuleTransport { + + name: string; + id: number; + code: string; + sourceCode: string; + sourcePath: string; + virtual: ?boolean; + meta: ?Metadata; + polyfill: ?boolean; + map: ?SourceMapOrMappings; + + constructor(data: { + name: string, + id: number, + code: string, + sourceCode: string, + sourcePath: string, + virtual?: ?boolean, + meta?: ?Metadata, + polyfill?: ?boolean, + map?: ?SourceMapOrMappings, + }) { + this.name = data.name; + + assertExists(data, 'id'); + this.id = data.id; + + assertExists(data, 'code'); + this.code = data.code; + + assertExists(data, 'sourceCode'); + this.sourceCode = data.sourceCode; + + assertExists(data, 'sourcePath'); + this.sourcePath = data.sourcePath; + + this.virtual = data.virtual; + this.meta = data.meta; + this.polyfill = data.polyfill; + this.map = data.map; + + Object.freeze(this); + } + +} + +module.exports = ModuleTransport; + +function assertExists(obj, field) { + if (obj[field] == null) { + throw new Error('Modules must have `' + field + '`'); + } +} diff --git a/packages/metro-bundler/react-packager/src/lib/SourceMap.js b/packages/metro-bundler/react-packager/src/lib/SourceMap.js new file mode 100644 index 00000000..d356cddb --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/SourceMap.js @@ -0,0 +1,33 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +import type {SourceMap as BabelSourceMap} from 'babel-core'; + +export type SourceMap = BabelSourceMap; + +export type CombinedSourceMap = { + version: number, + file?: string, + sections: Array<{ + offset: {line: number, column: number}, + map: MixedSourceMap, + }>, +}; + +type FBExtensions = {x_facebook_offsets?: Array}; + +export type MixedSourceMap + = SourceMap + | CombinedSourceMap + | (SourceMap & FBExtensions) + | (CombinedSourceMap & FBExtensions); diff --git a/packages/metro-bundler/react-packager/src/lib/TerminalReporter.js b/packages/metro-bundler/react-packager/src/lib/TerminalReporter.js new file mode 100644 index 00000000..6bf7f378 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/TerminalReporter.js @@ -0,0 +1,277 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const path = require('path'); +const reporting = require('./reporting'); +const terminal = require('./terminal'); +const throttle = require('lodash/throttle'); +const util = require('util'); + +import type {ReportableEvent, GlobalCacheDisabledReason} from './reporting'; + +const DEP_GRAPH_MESSAGE = 'Loading dependency graph'; +const GLOBAL_CACHE_DISABLED_MESSAGE_FORMAT = + 'The global cache is now disabled because %s'; + +type BundleProgress = { + transformedFileCount: number, + totalFileCount: number, + ratio: number, + outdatedModuleCount: number, +}; + +const DARK_BLOCK_CHAR = '\u2593'; +const LIGHT_BLOCK_CHAR = '\u2591'; + +function getProgressBar(ratio: number, length: number) { + const blockCount = Math.floor(ratio * length); + return ( + DARK_BLOCK_CHAR.repeat(blockCount) + + LIGHT_BLOCK_CHAR.repeat(length - blockCount) + ); +} + +export type TerminalReportableEvent = ReportableEvent | { + type: 'bundle_transform_progressed_throttled', + entryFilePath: string, + transformedFileCount: number, + totalFileCount: number, +}; + +/** + * We try to print useful information to the terminal for interactive builds. + * This implements the `Reporter` interface from the './reporting' module. + */ +class TerminalReporter { + + /** + * The bundle builds for which we are actively maintaining the status on the + * terminal, ie. showing a progress bar. There can be several bundles being + * built at the same time. + */ + _activeBundles: Map; + + _dependencyGraphHasLoaded: boolean; + _scheduleUpdateBundleProgress: (data: { + entryFilePath: string, + transformedFileCount: number, + totalFileCount: number, + }) => void; + + constructor() { + this._dependencyGraphHasLoaded = false; + this._activeBundles = new Map(); + this._scheduleUpdateBundleProgress = throttle((data) => { + this.update({...data, type: 'bundle_transform_progressed_throttled'}); + }, 200); + } + + /** + * Return a message looking like this: + * + * Transforming files |#### | 34.2% (324/945)... + * + */ + _getFileTransformMessage( + {totalFileCount, transformedFileCount, ratio, outdatedModuleCount}: BundleProgress, + build: 'in_progress' | 'done', + ): string { + if (outdatedModuleCount > 0) { + const plural = outdatedModuleCount > 1; + return `Updating ${outdatedModuleCount} ` + + `module${plural ? 's' : ''} in place` + + (build === 'done' ? ', done' : '...'); + } + if (totalFileCount === 0) { + return build === 'done' + ? 'No module changed.' + : 'Analysing...'; + } + return util.format( + 'Transforming modules %s%s% (%s/%s)%s', + build === 'done' ? '' : getProgressBar(ratio, 30) + ' ', + (100 * ratio).toFixed(1), + transformedFileCount, + totalFileCount, + build === 'done' ? ', done.' : '...', + ); + } + + /** + * Construct a message that represent the progress of a single bundle build. + */ + _getBundleStatusMessage( + entryFilePath: string, + progress: BundleProgress, + build: 'in_progress' | 'done', + ): string { + const localPath = path.relative('.', entryFilePath); + return [ + `Bundling \`${localPath}\``, + ' ' + this._getFileTransformMessage(progress, build), + ].join('\n'); + } + + _logCacheDisabled(reason: GlobalCacheDisabledReason): void { + const format = GLOBAL_CACHE_DISABLED_MESSAGE_FORMAT; + switch (reason) { + case 'too_many_errors': + reporting.logWarning(terminal, format, 'it has been failing too many times.'); + break; + case 'too_many_misses': + reporting.logWarning(terminal, format, 'it has been missing too many consecutive keys.'); + break; + } + } + + /** + * This function is only concerned with logging and should not do state + * or terminal status updates. + */ + _log(event: TerminalReportableEvent): void { + switch (event.type) { + case 'bundle_built': + const progress = this._activeBundles.get(event.entryFilePath); + if (progress != null) { + terminal.log( + this._getBundleStatusMessage(event.entryFilePath, progress, 'done'), + ); + } + break; + case 'dep_graph_loaded': + terminal.log(`${DEP_GRAPH_MESSAGE}, done.`); + break; + case 'global_cache_error': + const message = JSON.stringify(event.error.message); + reporting.logWarning(terminal, 'the global cache failed: %s', message); + break; + case 'global_cache_disabled': + this._logCacheDisabled(event.reason); + break; + } + } + + /** + * We use Math.pow(ratio, 2) to as a conservative measure of progress because + * we know the `totalCount` is going to progressively increase as well. We + * also prevent the ratio from going backwards. + */ + _updateBundleProgress( + {entryFilePath, transformedFileCount, totalFileCount}: { + entryFilePath: string, + transformedFileCount: number, + totalFileCount: number, + }, + ) { + const currentProgress = this._activeBundles.get(entryFilePath); + if (currentProgress == null) { + return; + } + const rawRatio = transformedFileCount / totalFileCount; + const conservativeRatio = Math.pow(rawRatio, 2); + const ratio = Math.max(conservativeRatio, currentProgress.ratio); + Object.assign(currentProgress, { + ratio, + transformedFileCount, + totalFileCount, + outdatedModuleCount: 0, + }); + } + + _updateBundleOutdatedModuleCount( + {entryFilePath, outdatedModuleCount}: { + entryFilePath: string, + outdatedModuleCount: number, + }, + ) { + const currentProgress = this._activeBundles.get(entryFilePath); + if (currentProgress == null) { + return; + } + currentProgress.outdatedModuleCount = outdatedModuleCount; + } + + /** + * This function is exclusively concerned with updating the internal state. + * No logging or status updates should be done at this point. + */ + _updateState(event: TerminalReportableEvent): void { + switch (event.type) { + case 'bundle_requested': + this._activeBundles.set(event.entryFilePath, { + transformedFileCount: 0, + totalFileCount: 0, + ratio: 0, + outdatedModuleCount: 0, + }); + break; + case 'bundle_transform_progressed': + if (event.totalFileCount === event.transformedFileCount) { + this._scheduleUpdateBundleProgress.cancel(); + this._updateBundleProgress(event); + } else { + this._scheduleUpdateBundleProgress(event); + } + break; + case 'bundle_transform_progressed_throttled': + this._updateBundleProgress(event); + break; + case 'bundle_update_existing': + this._updateBundleOutdatedModuleCount(event); + break; + case 'bundle_built': + this._activeBundles.delete(event.entryFilePath); + break; + case 'dep_graph_loading': + this._dependencyGraphHasLoaded = false; + break; + case 'dep_graph_loaded': + this._dependencyGraphHasLoaded = true; + break; + } + } + + _getDepGraphStatusMessage(): ?string { + if (!this._dependencyGraphHasLoaded) { + return `${DEP_GRAPH_MESSAGE}...`; + } + return null; + } + + /** + * Return a status message that is always consistent with the current state + * of the application. Having this single function ensures we don't have + * different callsites overriding each other status messages. + */ + _getStatusMessage(): string { + return [ + this._getDepGraphStatusMessage(), + ].concat(Array.from(this._activeBundles.entries()).map( + ([entryFilePath, progress]) => + this._getBundleStatusMessage(entryFilePath, progress, 'in_progress'), + )).filter(str => str != null).join('\n'); + } + + /** + * Everything that happens goes through the same 3 steps. This makes the + * output more reliable and consistent, because no matter what additional. + */ + update(event: TerminalReportableEvent) { + this._log(event); + this._updateState(event); + terminal.status(this._getStatusMessage()); + } + +} + +module.exports = TerminalReporter; diff --git a/packages/metro-bundler/react-packager/src/lib/TransformCache.js b/packages/metro-bundler/react-packager/src/lib/TransformCache.js new file mode 100644 index 00000000..7ff0b620 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/TransformCache.js @@ -0,0 +1,343 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const debugRead = require('debug')('RNP:TransformCache:Read'); +const fs = require('fs'); +/** + * We get the package "for free" with "write-file-atomic". MurmurHash3 is a + * faster hash, but non-cryptographic and insecure, that seems reasonnable for + * this particular use case. + */ +const imurmurhash = require('imurmurhash'); +const jsonStableStringify = require('json-stable-stringify'); +const mkdirp = require('mkdirp'); +const path = require('path'); +const rimraf = require('rimraf'); +const terminal = require('../lib/terminal'); +const toFixedHex = require('./toFixedHex'); +const writeFileAtomicSync = require('write-file-atomic').sync; + +const CACHE_NAME = 'react-native-packager-cache'; + +type CacheFilePaths = {transformedCode: string, metadata: string}; +import type {Options as TransformOptions} from '../JSTransformer/worker/worker'; +import type {SourceMap} from './SourceMap'; + +/** + * If packager is running for two different directories, we don't want the + * caches to conflict with each other. `__dirname` carries that because packager + * will be, for example, installed in a different `node_modules/` folder for + * different projects. + */ +const getCacheDirPath = (function () { + let dirPath; + return function () { + if (dirPath == null) { + dirPath = path.join( + require('os').tmpdir(), + CACHE_NAME + '-' + imurmurhash(__dirname).result().toString(16), + ); + + require('debug')('RNP:TransformCache:Dir')( + `transform cache directory: ${dirPath}` + ); + } + return dirPath; + }; +})(); + +function hashSourceCode(props: { + sourceCode: string, + transformCacheKey: string, +}): string { + return imurmurhash(props.transformCacheKey).hash(props.sourceCode).result(); +} + +/** + * The path, built as a hash, does not take the source code itself into account + * because it would generate lots of file during development. (The source hash + * is stored in the metadata instead). + */ +function getCacheFilePaths(props: { + filePath: string, + transformOptions: TransformOptions, +}): CacheFilePaths { + const hasher = imurmurhash() + .hash(props.filePath) + .hash(jsonStableStringify(props.transformOptions) || ''); + const hash = toFixedHex(8, hasher.result()); + const prefix = hash.substr(0, 2); + const fileName = `${hash.substr(2)}${path.basename(props.filePath)}`; + const base = path.join(getCacheDirPath(), prefix, fileName); + return {transformedCode: base, metadata: base + '.meta'}; +} + +export type CachedResult = { + code: string, + dependencies: Array, + dependencyOffsets: Array, + map?: ?SourceMap, +}; + +/** + * We want to unlink all cache files before writing, so that it is as much + * atomic as possible. + */ +function unlinkIfExistsSync(filePath: string) { + try { + fs.unlinkSync(filePath); + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + throw error; + } +} + +/** + * In the workers we are fine doing sync work because a worker is meant to + * process a single source file at a time. + * + * We store the transformed JS because it is likely to be much bigger than the + * rest of the data JSON. Probably the map should be stored separately as well. + * + * We make the write operation as much atomic as possible: indeed, if another + * process is reading the cache at the same time, there would be a risk it + * reads new transformed code, but old metadata. This is avoided by removing + * the files first. + * + * There is still a risk of conflincting writes, that is mitigated by hashing + * the result code, that is verified at the end. In case of writes happening + * close to each others, one of the workers is going to loose its results no + * matter what. + */ +function writeSync(props: { + filePath: string, + sourceCode: string, + transformCacheKey: string, + transformOptions: TransformOptions, + result: CachedResult, +}): void { + const cacheFilePath = getCacheFilePaths(props); + mkdirp.sync(path.dirname(cacheFilePath.transformedCode)); + const {result} = props; + unlinkIfExistsSync(cacheFilePath.transformedCode); + unlinkIfExistsSync(cacheFilePath.metadata); + writeFileAtomicSync(cacheFilePath.transformedCode, result.code); + writeFileAtomicSync(cacheFilePath.metadata, JSON.stringify([ + imurmurhash(result.code).result(), + hashSourceCode(props), + result.dependencies, + result.dependencyOffsets, + result.map, + ])); +} + +export type CacheOptions = {resetCache?: boolean}; + +/* 1 day */ +const GARBAGE_COLLECTION_PERIOD = 24 * 60 * 60 * 1000; +/* 4 days */ +const CACHE_FILE_MAX_LAST_ACCESS_TIME = GARBAGE_COLLECTION_PERIOD * 4; +/** + * Temporary folder is cleaned up only on boot, ex. on OS X, as far as I'm + * concerned. Since generally people reboot only very rarely, we need to clean + * up old stuff from time to time. + * + * This code should be safe even if two different React Native projects are + * running at the same time. + */ +const GARBAGE_COLLECTOR = new (class GarbageCollector { + + _lastCollected: ?number; + _cacheWasReset: boolean; + + constructor() { + this._cacheWasReset = false; + } + + _collectSync() { + const cacheDirPath = getCacheDirPath(); + mkdirp.sync(cacheDirPath); + const prefixDirs = fs.readdirSync(cacheDirPath); + for (let i = 0; i < prefixDirs.length; ++i) { + const prefixDir = path.join(cacheDirPath, prefixDirs[i]); + const cacheFileNames = fs.readdirSync(prefixDir); + for (let j = 0; j < cacheFileNames.length; ++j) { + const cacheFilePath = path.join(prefixDir, cacheFileNames[j]); + const stats = fs.lstatSync(cacheFilePath); + const timeSinceLastAccess = Date.now() - stats.atime.getTime(); + if ( + stats.isFile() && + timeSinceLastAccess > CACHE_FILE_MAX_LAST_ACCESS_TIME + ) { + fs.unlinkSync(cacheFilePath); + } + } + } + } + + /** + * We want to avoid preventing tool use if the cleanup fails for some reason, + * but still provide some chance for people to report/fix things. + */ + _collectSyncNoThrow() { + try { + this._collectSync(); + } catch (error) { + terminal.log(error.stack); + terminal.log( + 'Error: Cleaning up the cache folder failed. Continuing anyway.', + ); + terminal.log('The cache folder is: %s', getCacheDirPath()); + } + this._lastCollected = Date.now(); + } + + _resetCache() { + rimraf.sync(getCacheDirPath()); + terminal.log('Warning: The transform cache was reset.'); + this._cacheWasReset = true; + this._lastCollected = Date.now(); + } + + collectIfNecessarySync(options: CacheOptions) { + if (options.resetCache && !this._cacheWasReset) { + this._resetCache(); + return; + } + const lastCollected = this._lastCollected; + if ( + lastCollected == null || + Date.now() - lastCollected > GARBAGE_COLLECTION_PERIOD + ) { + this._collectSyncNoThrow(); + } + } + +})(); + +function readMetadataFileSync( + metadataFilePath: string, +): ?{ + cachedResultHash: number, + cachedSourceHash: number, + dependencies: Array, + dependencyOffsets: Array, + sourceMap: ?SourceMap, +} { + const metadataStr = fs.readFileSync(metadataFilePath, 'utf8'); + let metadata; + try { + metadata = JSON.parse(metadataStr); + } catch (error) { + if (error instanceof SyntaxError) { + return null; + } + throw error; + } + if (!Array.isArray(metadata)) { + return null; + } + const [ + cachedResultHash, + cachedSourceHash, + dependencies, + dependencyOffsets, + sourceMap, + ] = metadata; + if ( + typeof cachedResultHash !== 'number' || + typeof cachedSourceHash !== 'number' || + !( + Array.isArray(dependencies) && + dependencies.every(dep => typeof dep === 'string') + ) || + !( + Array.isArray(dependencyOffsets) && + dependencyOffsets.every(offset => typeof offset === 'number') + ) || + !(sourceMap == null || typeof sourceMap === 'object') + ) { + return null; + } + return { + cachedResultHash, + cachedSourceHash, + dependencies, + dependencyOffsets, + sourceMap, + }; +} + +export type ReadTransformProps = { + filePath: string, + sourceCode: string, + transformOptions: TransformOptions, + transformCacheKey: string, + cacheOptions: CacheOptions, +}; + +/** + * We verify the source hash matches to ensure we always favor rebuilding when + * source change (rather than just using fs.mtime(), a bit less robust). + * + * That means when the source changes, we override the old transformed code with + * the new one. This is, I believe, preferable, so as to avoid bloating the + * cache during development cycles, where people changes files all the time. + * If we implement a global cache ability at some point, we'll be able to store + * old artifacts as well. + * + * Meanwhile we store transforms with different options in different files so + * that it is fast to switch between ex. minified, or not. + */ +function readSync(props: ReadTransformProps): ?CachedResult { + GARBAGE_COLLECTOR.collectIfNecessarySync(props.cacheOptions); + const cacheFilePaths = getCacheFilePaths(props); + let metadata, transformedCode; + try { + metadata = readMetadataFileSync(cacheFilePaths.metadata); + if (metadata == null) { + return null; + } + const sourceHash = hashSourceCode(props); + if (sourceHash !== metadata.cachedSourceHash) { + return null; + } + transformedCode = fs.readFileSync(cacheFilePaths.transformedCode, 'utf8'); + if (metadata.cachedResultHash !== imurmurhash(transformedCode).result()) { + return null; + } + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } + return { + code: transformedCode, + dependencies: metadata.dependencies, + dependencyOffsets: metadata.dependencyOffsets, + map: metadata.sourceMap, + }; +} + +module.exports = { + writeSync, + readSync(props: ReadTransformProps): ?CachedResult { + const result = readSync(props); + const msg = result ? 'Cache hit: ' : 'Cache miss: '; + debugRead(msg + props.filePath); + return result; + } +}; diff --git a/packages/metro-bundler/react-packager/src/lib/__mocks__/GlobalTransformCache.js b/packages/metro-bundler/react-packager/src/lib/__mocks__/GlobalTransformCache.js new file mode 100644 index 00000000..f741d772 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__mocks__/GlobalTransformCache.js @@ -0,0 +1,18 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +function get() { + return null; +} + +module.exports = {get}; diff --git a/packages/metro-bundler/react-packager/src/lib/__mocks__/TransformCache.js b/packages/metro-bundler/react-packager/src/lib/__mocks__/TransformCache.js new file mode 100644 index 00000000..84e9143c --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__mocks__/TransformCache.js @@ -0,0 +1,44 @@ +/** + * 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 imurmurhash = require('imurmurhash'); +const jsonStableStringify = require('json-stable-stringify'); + +const transformCache = new Map(); + +const mock = { + lastWrite: null, + reset() { + transformCache.clear(); + mock.lastWrite = null; + }, +}; + +const transformCacheKeyOf = (props) => + props.filePath + '-' + imurmurhash(props.sourceCode) + .hash(props.transformCacheKey) + .hash(jsonStableStringify(props.transformOptions || {})) + .result().toString(16); + +function writeSync(props) { + transformCache.set(transformCacheKeyOf(props), props.result); + mock.lastWrite = props; +} + +function readSync(props) { + return transformCache.get(transformCacheKeyOf(props)); +} + +module.exports = { + writeSync, + readSync, + mock, +}; diff --git a/packages/metro-bundler/react-packager/src/lib/__mocks__/declareOpts.js b/packages/metro-bundler/react-packager/src/lib/__mocks__/declareOpts.js new file mode 100644 index 00000000..7b54b27d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__mocks__/declareOpts.js @@ -0,0 +1,20 @@ +/** + * 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'; + +module.exports = function(declared) { + return function(opts) { + for (var p in declared) { + if (opts[p] == null && declared[p].default != null){ + opts[p] = declared[p].default; + } + } + return opts; + }; +}; diff --git a/packages/metro-bundler/react-packager/src/lib/__tests__/TransformCache-test.js b/packages/metro-bundler/react-packager/src/lib/__tests__/TransformCache-test.js new file mode 100644 index 00000000..e2e3bece --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__tests__/TransformCache-test.js @@ -0,0 +1,135 @@ +/** + * 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 + .dontMock('imurmurhash') + .dontMock('json-stable-stringify') + .dontMock('../TransformCache') + .dontMock('../toFixedHex') + .dontMock('left-pad'); + +const imurmurhash = require('imurmurhash'); + +const memoryFS = new Map(); + +jest.mock('fs', () => ({ + readFileSync(filePath) { + return memoryFS.get(filePath); + }, + unlinkSync(filePath) { + memoryFS.delete(filePath); + }, + readdirSync(dirPath) { + // Not required for it to work. + return []; + } +})); + +jest.mock('write-file-atomic', () => ({ + sync(filePath, data) { + memoryFS.set(filePath, data.toString()); + }, +})); + +jest.mock('rimraf', () => () => {}); + +function cartesianProductOf(a1, a2) { + const product = []; + a1.forEach(e1 => a2.forEach(e2 => product.push([e1, e2]))); + return product; +} + +describe('TransformCache', () => { + + let TransformCache; + + beforeEach(() => { + jest.resetModules(); + memoryFS.clear(); + TransformCache = require('../TransformCache'); + }); + + it('is caching different files and options separately', () => { + const transformCacheKey = 'abcdef'; + const argsFor = ([filePath, transformOptions]) => { + const key = filePath + JSON.stringify(transformOptions); + return { + sourceCode: `/* source for ${key} */`, + transformCacheKey, + filePath, + transformOptions, + result: { + code: `/* result for ${key} */`, + dependencies: ['foo', `dep of ${key}`], + dependencyOffsets: [12, imurmurhash('dep' + key).result()], + map: {desc: `source map for ${key}`}, + }, + }; + }; + const allCases = cartesianProductOf( + ['/some/project/sub/dir/file.js', '/some/project/other.js'], + [{foo: 1}, {foo: 2}], + ); + allCases.forEach( + entry => TransformCache.writeSync(argsFor(entry)), + ); + allCases.forEach(entry => { + const args = argsFor(entry); + const {result} = args; + const cachedResult = TransformCache.readSync({ + ...args, + cacheOptions: {resetCache: false}, + }); + expect(cachedResult).toEqual(result); + }); + }); + + it('is overriding cache when source code or transform key changes', () => { + const argsFor = ([sourceCode, transformCacheKey]) => { + const key = sourceCode + transformCacheKey; + return { + sourceCode, + transformCacheKey, + filePath: 'test.js', + transformOptions: {foo: 1}, + result: { + code: `/* result for ${key} */`, + dependencies: ['foo', `dep of ${key}`], + dependencyOffsets: [12, imurmurhash('dep' + key).result()], + map: {desc: `source map for ${key}`}, + }, + }; + }; + const allCases = cartesianProductOf( + ['/* foo */', '/* bar */'], + ['abcd', 'efgh'], + ); + allCases.forEach(entry => { + TransformCache.writeSync(argsFor(entry)); + const args = argsFor(entry); + const {result} = args; + const cachedResult = TransformCache.readSync({ + ...args, + cacheOptions: {resetCache: false}, + }); + expect(cachedResult).toEqual(result); + }); + allCases.pop(); + allCases.forEach(entry => { + const cachedResult = TransformCache.readSync({ + ...argsFor(entry), + cacheOptions: {resetCache: false}, + }); + expect(cachedResult).toBeNull(); + }); + }); + +}); diff --git a/packages/metro-bundler/react-packager/src/lib/__tests__/declareOpts-test.js b/packages/metro-bundler/react-packager/src/lib/__tests__/declareOpts-test.js new file mode 100644 index 00000000..c67fa230 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__tests__/declareOpts-test.js @@ -0,0 +1,92 @@ +/** + * 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.disableAutomock(); + +var declareOpts = require('../declareOpts'); + +describe('declareOpts', function() { + it('should declare and validate simple opts', function() { + var validate = declareOpts({ + name: { + required: true, + type: 'string', + }, + age: { + type: 'number', + default: 21, + } + }); + var opts = validate({ name: 'fooer' }); + + expect(opts).toEqual({ + name: 'fooer', + age: 21 + }); + }); + + it('should work with complex types', function() { + var validate = declareOpts({ + things: { + required: true, + type: 'array', + }, + stuff: { + type: 'object', + required: true, + } + }); + + var opts = validate({ things: [1, 2, 3], stuff: {hai: 1} }); + expect(opts).toEqual({ + things: [1,2,3], + stuff: {hai: 1}, + }); + }); + + it('should throw when a required option is not present', function() { + var validate = declareOpts({ + foo: { + required: true, + type: 'number', + } + }); + + expect(function() { + validate({}); + }).toThrow(); + }); + + it('should throw on invalid type', function() { + var validate = declareOpts({ + foo: { + required: true, + type: 'number' + } + }); + + expect(function() { + validate({foo: 'lol'}); + }).toThrow(); + }); + + it('should throw on extra options', function() { + var validate = declareOpts({ + foo: { + required: true, + type: 'number', + } + }); + + expect(function() { + validate({foo: 1, lol: 1}); + }).toThrow(); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/lib/__tests__/terminal-test.js b/packages/metro-bundler/react-packager/src/lib/__tests__/terminal-test.js new file mode 100644 index 00000000..5a86a68a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/__tests__/terminal-test.js @@ -0,0 +1,100 @@ +/** + * 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.dontMock('../terminal'); + +jest.mock('readline', () => ({ + moveCursor: (stream, dx, dy) => { + const {cursor, columns} = stream; + stream.cursor = Math.max(cursor - cursor % columns, cursor + dx) + dy * columns; + }, + clearLine: (stream, dir) => { + if (dir !== 0) {throw new Error('unsupported');} + const {cursor, columns} = stream; + const curLine = cursor - cursor % columns; + const nextLine = curLine + columns; + for (var i = curLine; i < nextLine; ++i) { + stream.buffer[i] = ' '; + } + }, +})); + +describe('terminal', () => { + + beforeEach(() => { + jest.resetModules(); + }); + + function prepare(isTTY) { + const {Terminal} = require('../terminal'); + const lines = 10; + const columns = 10; + const stream = Object.create( + isTTY ? require('tty').WriteStream.prototype : require('net').Socket, + ); + Object.assign(stream, { + cursor: 0, + buffer: ' '.repeat(columns * lines).split(''), + columns, + lines, + write(str) { + for (let i = 0; i < str.length; ++i) { + if (str[i] === '\n') { + this.cursor = this.cursor - (this.cursor % columns) + columns; + } else { + this.buffer[this.cursor] = str[i]; + ++this.cursor; + } + } + }, + }); + return {stream, terminal: new Terminal(stream)}; + } + + it('is not printing status to non-interactive terminal', () => { + const {stream, terminal} = prepare(false); + terminal.log('foo %s', 'smth'); + terminal.status('status'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo smth bar'); + }); + + it('updates status when logging, single line', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status'); + terminal.status('status2'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo bar status2'); + }); + + it('updates status when logging, multi-line', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status\nanother'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo bar status another'); + }); + + it('persists status', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status'); + terminal.persistStatus(); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo status bar'); + }); + +}); diff --git a/packages/metro-bundler/react-packager/src/lib/declareOpts.js b/packages/metro-bundler/react-packager/src/lib/declareOpts.js new file mode 100644 index 00000000..719da941 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/declareOpts.js @@ -0,0 +1,75 @@ +/** + * 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. + * + * Declares, validates and defaults options. + * var validate = declareOpts({ + * foo: { + * type: 'bool', + * required: true, + * } + * }); + * + * var myOptions = validate(someOptions); + * + * @flow + */ + +'use strict'; + +var Joi = require('joi'); + +/** + * TOut is always more specific than TIn, so it's a subtype. + */ +module.exports = function( + descriptor: {[name: string]: { + type: mixed, + required?: boolean, + default?: mixed, + }}, +): (untyped: TIn) => TOut { + var joiKeys = {}; + Object.keys(descriptor).forEach(function(prop) { + var record = descriptor[prop]; + if (record.type == null) { + throw new Error('Type is required'); + } + + if (record.type === 'function') { + record.type = 'func'; + } + + var propValidator = Joi[record.type](); + + if (record.required) { + propValidator = propValidator.required(); + } + + if (record.default) { + propValidator = propValidator.default(record.default); + } + + joiKeys[prop] = propValidator; + }); + + var schema = Joi.object().keys(joiKeys); + + return function(opts) { + opts = opts || {}; + + var res = Joi.validate(opts, schema, { + abortEarly: true, + allowUnknown: false, + }); + + if (res.error) { + throw new Error('Error validating module options: ' + res.error.message); + } + return res.value; + }; +}; diff --git a/packages/metro-bundler/react-packager/src/lib/relativizeSourceMap.js b/packages/metro-bundler/react-packager/src/lib/relativizeSourceMap.js new file mode 100644 index 00000000..79b0e804 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/relativizeSourceMap.js @@ -0,0 +1,38 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const path = require('path'); + +import type { MixedSourceMap } from './SourceMap'; + +function relativizeSourceMapInternal(sourceMap: any, sourcesRoot: string) { + if (sourceMap.sections) { + for (var i = 0; i < sourceMap.sections.length; i++) { + relativizeSourceMapInternal(sourceMap.sections[i].map, sourcesRoot); + } + } else { + for (var i = 0; i < sourceMap.sources.length; i++) { + sourceMap.sources[i] = path.relative(sourcesRoot, sourceMap.sources[i]); + } + } +} + +function relativizeSourceMap(sourceMap: MixedSourceMap, sourcesRoot?: string): MixedSourceMap { + if (!sourcesRoot) { + return sourceMap; + } + relativizeSourceMapInternal(sourceMap, sourcesRoot); + return sourceMap; +} + +module.exports = relativizeSourceMap; diff --git a/packages/metro-bundler/react-packager/src/lib/reporting.js b/packages/metro-bundler/react-packager/src/lib/reporting.js new file mode 100644 index 00000000..8e086dc0 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/reporting.js @@ -0,0 +1,94 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const chalk = require('chalk'); +const util = require('util'); + +import type {Terminal} from './terminal'; + +export type GlobalCacheDisabledReason = 'too_many_errors' | 'too_many_misses'; + +/** + * A tagged union of all the actions that may happen and we may want to + * report to the tool user. + */ +export type ReportableEvent = { + type: 'dep_graph_loading', +} | { + type: 'dep_graph_loaded', +} | { + type: 'bundle_requested', + entryFilePath: string, +} | { + type: 'bundle_transform_progressed', + entryFilePath: string, + transformedFileCount: number, + totalFileCount: number, +} | { + entryFilePath: string, + outdatedModuleCount: number, + type: 'bundle_update_existing', +} | { + type: 'bundle_built', + entryFilePath: string, +} | { + type: 'global_cache_error', + error: Error, +} | { + type: 'global_cache_disabled', + reason: GlobalCacheDisabledReason, +}; + +/** + * Code across the application takes a reporter as an option and calls the + * update whenever one of the ReportableEvent happens. Code does not directly + * write to the standard output, because a build would be: + * + * 1. ad-hoc, embedded into another tool, in which case we do not want to + * pollute that tool's own output. The tool is free to present the + * warnings/progress we generate any way they want, by specifing a custom + * reporter. + * 2. run as a background process from another tool, in which case we want + * to expose updates in a way that is easily machine-readable, for example + * a JSON-stream. We don't want to pollute it with textual messages. + * + * We centralize terminal reporting into a single place because we want the + * output to be robust and consistent. The most common reporter is + * TerminalReporter, that should be the only place in the application should + * access the `terminal` module (nor the `console`). + */ +export type Reporter = { + update(event: ReportableEvent): void, +}; + +/** + * A standard way to log a warning to the terminal. This should not be called + * from some arbitrary packager logic, only from the reporters. Instead of + * calling this, add a new type of ReportableEvent instead, and implement a + * proper handler in the reporter(s). + */ +function logWarning(terminal: Terminal, format: string, ...args: Array): void { + const str = util.format(format, ...args); + terminal.log('%s: %s', chalk.yellow('warning'), str); +} + +/** + * A reporter that does nothing. Errors and warnings will be swallowed, that + * is generally not what you want. + */ +const nullReporter = {update() {}}; + +module.exports = { + logWarning, + nullReporter, +}; diff --git a/packages/metro-bundler/react-packager/src/lib/terminal.js b/packages/metro-bundler/react-packager/src/lib/terminal.js new file mode 100644 index 00000000..facc56f2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/terminal.js @@ -0,0 +1,137 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const readline = require('readline'); +const tty = require('tty'); +const util = require('util'); + +/** + * Clear some text that was previously printed on an interactive stream, + * without trailing newline character (so we have to move back to the + * beginning of the line). + */ +function clearStringBackwards(stream: tty.WriteStream, str: string): void { + readline.moveCursor(stream, -stream.columns, 0); + readline.clearLine(stream, 0); + let lineCount = (str.match(/\n/g) || []).length; + while (lineCount > 0) { + readline.moveCursor(stream, 0, -1); + readline.clearLine(stream, 0); + --lineCount; + } +} + +/** + * We don't just print things to the console, sometimes we also want to show + * and update progress. This utility just ensures the output stays neat: no + * missing newlines, no mangled log lines. + * + * const terminal = Terminal.default; + * terminal.status('Updating... 38%'); + * terminal.log('warning: Something happened.'); + * terminal.status('Updating, done.'); + * terminal.persistStatus(); + * + * The final output: + * + * warning: Something happened. + * Updating, done. + * + * Without the status feature, we may get a mangled output: + * + * Updating... 38%warning: Something happened. + * Updating, done. + * + * This is meant to be user-readable and TTY-oriented. We use stdout by default + * because it's more about status information than diagnostics/errors (stderr). + * + * Do not add any higher-level functionality in this class such as "warning" and + * "error" printers, as it is not meant for formatting/reporting. It has the + * single responsibility of handling status messages. + */ +class Terminal { + + _statusStr: string; + _stream: net$Socket; + + constructor(stream: net$Socket) { + this._stream = stream; + this._statusStr = ''; + } + + /** + * Same as status() without the formatting capabilities. We just clear and + * rewrite with the new status. If the stream is non-interactive we still + * keep track of the string so that `persistStatus` works. + */ + _setStatus(str: string): string { + const {_statusStr, _stream} = this; + if (_statusStr !== str && _stream instanceof tty.WriteStream) { + clearStringBackwards(_stream, _statusStr); + _stream.write(str); + } + this._statusStr = str; + return _statusStr; + } + + /** + * Shows some text that is meant to be overriden later. Return the previous + * status that was shown and is no more. Calling `status()` with no argument + * removes the status altogether. The status is never shown in a + * non-interactive terminal: for example, if the output is redirected to a + * file, then we don't care too much about having a progress bar. + */ + status(format: string, ...args: Array): string { + return this._setStatus(util.format(format, ...args)); + } + + /** + * Similar to `console.log`, except it moves the status/progress text out of + * the way correctly. In non-interactive terminals this is the same as + * `console.log`. + */ + log(format: string, ...args: Array): void { + const oldStatus = this._setStatus(''); + this._stream.write(util.format(format, ...args) + '\n'); + this._setStatus(oldStatus); + } + + /** + * Log the current status and start from scratch. This is useful if the last + * status was the last one of a series of updates. + */ + persistStatus(): void { + return this.log(this.status('')); + } + +} + +/** + * On the same pattern as node.js `console` module, we export the stdout-based + * terminal at the top-level, but provide access to the Terminal class as a + * field (so it can be used, for instance, with stderr). + */ +class GlobalTerminal extends Terminal { + + Terminal: Class; + + constructor() { + /* $FlowFixMe: Flow is wrong, Node.js docs specify that process.stdout is an + * instance of a net.Socket (a local socket, not network). */ + super(process.stdout); + this.Terminal = Terminal; + } + +} + +module.exports = new GlobalTerminal(); diff --git a/packages/metro-bundler/react-packager/src/lib/toFixedHex.js b/packages/metro-bundler/react-packager/src/lib/toFixedHex.js new file mode 100644 index 00000000..3952e15a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/lib/toFixedHex.js @@ -0,0 +1,20 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const leftPad = require('left-pad'); + +function toFixedHex(length: number, number: number): string { + return leftPad(number.toString(16), length, '0'); +} + +module.exports = toFixedHex; diff --git a/packages/metro-bundler/react-packager/src/node-haste/AssetModule.js b/packages/metro-bundler/react-packager/src/node-haste/AssetModule.js new file mode 100644 index 00000000..21a12ef2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/AssetModule.js @@ -0,0 +1,69 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Module = require('./Module'); + +const getAssetDataFromName = require('./lib/getAssetDataFromName'); + +import type {ConstructorArgs, ReadResult} from './Module'; + +class AssetModule extends Module { + + resolution: mixed; + _name: string; + _type: string; + _dependencies: Array; + + constructor(args: ConstructorArgs & {dependencies: Array}, platforms: Set) { + super(args); + const { resolution, name, type } = getAssetDataFromName(this.path, platforms); + this.resolution = resolution; + this._name = name; + this._type = type; + this._dependencies = args.dependencies || []; + } + + isHaste() { + return Promise.resolve(false); + } + + getDependencies() { + return Promise.resolve(this._dependencies); + } + + read(): Promise { + /** $FlowFixMe: improper OOP design. AssetModule, being different from a + * normal Module, shouldn't inherit it in the first place. */ + return Promise.resolve({}); + } + + getName() { + return super.getName().then( + id => id.replace(/\/[^\/]+$/, `/${this._name}.${this._type}`) + ); + } + + hash() { + return `AssetModule : ${this.path}`; + } + + isJSON() { + return false; + } + + isAsset() { + return true; + } +} + +module.exports = AssetModule; diff --git a/packages/metro-bundler/react-packager/src/node-haste/Cache/__mocks__/index.js b/packages/metro-bundler/react-packager/src/node-haste/Cache/__mocks__/index.js new file mode 100644 index 00000000..6f7632f6 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Cache/__mocks__/index.js @@ -0,0 +1,20 @@ +/** + * 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'; + +class Cache { + get(filepath, field, cb) { + return cb(filepath); + } + + invalidate(filepath) { } + end() { } +} + +module.exports = Cache; diff --git a/packages/metro-bundler/react-packager/src/node-haste/Cache/__tests__/Cache-test.js b/packages/metro-bundler/react-packager/src/node-haste/Cache/__tests__/Cache-test.js new file mode 100644 index 00000000..d56f8130 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Cache/__tests__/Cache-test.js @@ -0,0 +1,360 @@ +/** + * 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 + .dontMock('absolute-path') + .dontMock('../'); + +jest + .mock('fs') + .setMock('os', { + tmpdir() { return 'tmpdir'; }, + }); + +jest.useRealTimers(); + + +describe('Cache', () => { + let Cache, fs; + beforeEach(() => { + Cache = require('../'); + fs = require('graceful-fs'); + }); + + describe('getting/setting', () => { + it('calls loader callback for uncached file', () => { + fs.stat.mockImplementation((file, callback) => { + callback(null, { + mtime: { + getTime: () => {}, + }, + }); + }); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => Promise.resolve()); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .then($ => + expect(loaderCb).toBeCalledWith('/rootDir/someFile') + ); + }); + + it('supports storing multiple fields', () => { + fs.stat.mockImplementation((file, callback) => { + callback(null, { + mtime: { + getTime: () => {}, + }, + }); + }); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var index = 0; + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve(index++) + ); + + return cache + .get('/rootDir/someFile', 'field1', loaderCb) + .then(value => { + expect(value).toBe(0); + return cache + .get('/rootDir/someFile', 'field2', loaderCb) + .then(value2 => expect(value2).toBe(1)); + }); + }); + + it('gets the value from the loader callback', () => { + fs.stat.mockImplementation((file, callback) => + callback(null, { + mtime: { + getTime: () => {}, + }, + }) + ); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('lol') + ); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .then(value => expect(value).toBe('lol')); + }); + + it('caches the value after the first call', () => { + fs.stat.mockImplementation((file, callback) => { + callback(null, { + mtime: { + getTime: () => {}, + }, + }); + }); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('lol') + ); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .then(() => { + var shouldNotBeCalled = jest.genMockFn(); + return cache.get('/rootDir/someFile', 'field', shouldNotBeCalled) + .then(value => { + expect(shouldNotBeCalled).not.toBeCalled(); + expect(value).toBe('lol'); + }); + }); + }); + + it('clears old field when getting new field and mtime changed', () => { + var mtime = 0; + fs.stat.mockImplementation((file, callback) => { + callback(null, { + mtime: { + getTime: () => mtime++, + }, + }); + }); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('lol' + mtime) + ); + + return cache + .get('/rootDir/someFile', 'field1', loaderCb) + .then(value => cache + .get('/rootDir/someFile', 'field2', loaderCb) + .then(value2 => cache + .get('/rootDir/someFile', 'field1', loaderCb) + .then(value3 => expect(value3).toBe('lol2')) + ) + ); + }); + + it('does not cache rejections', () => { + fs.stat.mockImplementation((file, callback) => { + callback(null, { + mtime: { + getTime: () => {}, + }, + }); + }); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = () => Promise.reject('lol'); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .catch(() => { + var shouldBeCalled = jest.fn(() => Promise.resolve()); + const assert = value => expect(shouldBeCalled).toBeCalled(); + return cache.get('/rootDir/someFile', 'field', shouldBeCalled) + .then(assert, assert); + }); + }); + }); + + describe('loading cache from disk', () => { + var fileStats; + + beforeEach(() => { + fileStats = { + '/rootDir/someFile': { + mtime: { + getTime: () => 22, + }, + }, + '/rootDir/foo': { + mtime: { + getTime: () => 11, + }, + }, + }; + + fs.existsSync.mockImplementation(() => true); + + fs.statSync.mockImplementation(filePath => fileStats[filePath]); + + fs.readFileSync.mockImplementation(() => JSON.stringify({ + '/rootDir/someFile': { + metadata: {mtime: 22}, + data: {field: 'oh hai'}, + }, + '/rootDir/foo': { + metadata: {mtime: 11}, + data: {field: 'lol wat'}, + }, + })); + }); + + it('should load cache from disk', () => { + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn(); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .then(value => { + expect(loaderCb).not.toBeCalled(); + expect(value).toBe('oh hai'); + + return cache + .get('/rootDir/foo', 'field', loaderCb) + .then(val => { + expect(loaderCb).not.toBeCalled(); + expect(val).toBe('lol wat'); + }); + }); + }); + + it('should not load outdated cache', () => { + fs.stat.mockImplementation((file, callback) => + callback(null, { + mtime: { + getTime: () => {}, + }, + }) + ); + + fileStats['/rootDir/foo'].mtime.getTime = () => 123; + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('new value') + ); + + return cache + .get('/rootDir/someFile', 'field', loaderCb) + .then(value => { + expect(loaderCb).not.toBeCalled(); + expect(value).toBe('oh hai'); + + return cache + .get('/rootDir/foo', 'field', loaderCb) + .then(val => { + expect(loaderCb).toBeCalled(); + expect(val).toBe('new value'); + }); + }); + }); + }); + + describe('writing cache to disk', () => { + it('should write cache to disk', (done) => { + var index = 0; + var mtimes = [10, 20, 30]; + + fs.stat.mockImplementation((file, callback) => + callback(null, { + mtime: { + getTime: () => mtimes[index++], + }, + }) + ); + + var cache = new Cache({ + cacheKey: 'cache', + }); + + cache.get('/rootDir/bar', 'field', () => + Promise.resolve('bar value') + ); + cache.get('/rootDir/foo', 'field', () => + Promise.resolve('foo value') + ); + cache.get('/rootDir/baz', 'field', () => + Promise.resolve('baz value') + ); + + setTimeout(() => { + expect(fs.writeFile).toBeCalled(); + done(); + }, 2020); + }); + }); + + describe('check for cache presence', () => { + it('synchronously resolves cache presence', () => { + fs.stat.mockImplementation((file, callback) => + callback(null, { + mtime: { + getTime: () => {}, + }, + }) + ); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('banana') + ); + var file = '/rootDir/someFile'; + + return cache + .get(file, 'field', loaderCb) + .then(() => { + expect(cache.has(file)).toBe(true); + expect(cache.has(file, 'field')).toBe(true); + expect(cache.has(file, 'foo')).toBe(false); + }); + }); + }); + + describe('invalidate', () => { + it('invalidates the cache per file or per-field', () => { + fs.stat.mockImplementation((file, callback) => + callback(null, { + mtime: { + getTime: () => {}, + }, + }) + ); + + var cache = new Cache({ + cacheKey: 'cache', + }); + var loaderCb = jest.genMockFn().mockImplementation(() => + Promise.resolve('banana') + ); + var file = '/rootDir/someFile'; + + return cache.get(file, 'field', loaderCb).then(() => { + expect(cache.has(file)).toBe(true); + cache.invalidate(file, 'field'); + expect(cache.has(file)).toBe(true); + expect(cache.has(file, 'field')).toBe(false); + cache.invalidate(file); + expect(cache.has(file)).toBe(false); + }); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/Cache/index.js b/packages/metro-bundler/react-packager/src/node-haste/Cache/index.js new file mode 100644 index 00000000..fb2a2289 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Cache/index.js @@ -0,0 +1,254 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const crypto = require('crypto'); +const denodeify = require('denodeify'); +const fs = require('graceful-fs'); +const isAbsolutePath = require('absolute-path'); +const path = require('path'); +const tmpDir = require('os').tmpdir(); + +function getObjectValues(object: {[key: string]: T}): Array { + return Object.keys(object).map(key => object[key]); +} + +function debounce(fn, delay) { + var timeout; + return () => { + clearTimeout(timeout); + timeout = setTimeout(fn, delay); + }; +} + +type Record = { + data: {[field: string]: Promise}, + metadata: {[field: string]: Promise}, +}; + +class Cache { + + _cacheFilePath: string; + _data: {[filename: string]: Record}; + _persistEventually: () => void; + _persisting: ?Promise | void; + + constructor({ + resetCache, + cacheKey, + cacheDirectory = tmpDir, + }: { + resetCache: boolean, + cacheKey: string, + cacheDirectory?: string, + }) { + this._cacheFilePath = Cache.getCacheFilePath(cacheDirectory, cacheKey); + if (!resetCache) { + this._data = this._loadCacheSync(this._cacheFilePath); + } else { + this._data = Object.create(null); + } + + this._persistEventually = debounce(this._persistCache.bind(this), 2000); + } + + static getCacheFilePath(tmpdir, ...args) { + const hash = crypto.createHash('md5'); + args.forEach(arg => hash.update(arg)); + return path.join(tmpdir, hash.digest('hex')); + } + + get( + filepath: string, + field: string, + loaderCb: (filepath: string) => Promise, + ): Promise { + if (!isAbsolutePath(filepath)) { + throw new Error('Use absolute paths'); + } + + return this.has(filepath, field) + /* $FlowFixMe: this class is unsound as a whole because it uses + * untyped storage where in fact each "field" has a particular type. + * We cannot express this using Flow. */ + ? (this._data[filepath].data[field]: Promise) + : this.set(filepath, field, loaderCb(filepath)); + } + + invalidate(filepath: string, field: ?string) { + if (this.has(filepath, field)) { + if (field == null) { + delete this._data[filepath]; + } else { + delete this._data[filepath].data[field]; + } + } + } + + end() { + return this._persistCache(); + } + + has(filepath: string, field: ?string) { + return Object.prototype.hasOwnProperty.call(this._data, filepath) && + (field == null || Object.prototype.hasOwnProperty.call(this._data[filepath].data, field)); + } + + set( + filepath: string, + field: string, + loaderPromise: Promise, + ): Promise { + let record = this._data[filepath]; + if (!record) { + // $FlowFixMe: temporarily invalid record. + record = (Object.create(null): Record); + this._data[filepath] = record; + this._data[filepath].data = Object.create(null); + this._data[filepath].metadata = Object.create(null); + } + + const cachedPromise = record.data[field] = loaderPromise + .then(data => Promise.all([ + data, + denodeify(fs.stat)(filepath), + ])) + .then(([data, stat]) => { + this._persistEventually(); + + // Evict all existing field data from the cache if we're putting new + // more up to date data + var mtime = stat.mtime.getTime(); + if (record.metadata.mtime !== mtime) { + record.data = Object.create(null); + } + record.metadata.mtime = mtime; + + return data; + }); + + // don't cache rejected promises + cachedPromise.catch(error => delete record.data[field]); + return cachedPromise; + } + + _persistCache() { + if (this._persisting != null) { + return this._persisting; + } + + const data = this._data; + const cacheFilepath = this._cacheFilePath; + + const allPromises = getObjectValues(data) + .map(record => { + const fieldNames = Object.keys(record.data); + const fieldValues = getObjectValues(record.data); + + return Promise + .all(fieldValues) + .then(ref => { + // $FlowFixMe: temporarily invalid record. + const ret = (Object.create(null): Record); + ret.metadata = record.metadata; + ret.data = Object.create(null); + /* $FlowFixMe(>=0.36.0 site=react_native_fb,react_native_oss) Flow + * error detected during the deploy of Flow v0.36.0. To see the + * error, remove this comment and run Flow */ + fieldNames.forEach((field, index) => + ret.data[field] = ref[index] + ); + + return ret; + }); + } + ); + + this._persisting = Promise.all(allPromises) + .then(values => { + const json = Object.create(null); + Object.keys(data).forEach((key, i) => { + // make sure the key wasn't added nor removed after we started + // persisting the cache + const value = values[i]; + if (!value) { + return; + } + + json[key] = Object.create(null); + json[key].metadata = data[key].metadata; + json[key].data = value.data; + }); + return denodeify(fs.writeFile)(cacheFilepath, JSON.stringify(json)); + }) + .catch(e => console.error( + '[node-haste] Encountered an error while persisting cache:\n%s', + e.stack.split('\n').map(line => '> ' + line).join('\n') + )) + .then(() => { + this._persisting = null; + return true; + }); + + return this._persisting; + } + + _loadCacheSync(cachePath) { + var ret = Object.create(null); + var cacheOnDisk = loadCacheSync(cachePath); + + // Filter outdated cache and convert to promises. + Object.keys(cacheOnDisk).forEach(key => { + if (!fs.existsSync(key)) { + return; + } + var record = cacheOnDisk[key]; + var stat = fs.statSync(key); + if (stat.mtime.getTime() === record.metadata.mtime) { + ret[key] = Object.create(null); + ret[key].metadata = Object.create(null); + ret[key].data = Object.create(null); + // $FlowFixMe: we should maybe avoid Object.create(). + ret[key].metadata.mtime = record.metadata.mtime; + + Object.keys(record.data).forEach(field => { + ret[key].data[field] = Promise.resolve(record.data[field]); + }); + } + }); + + return ret; + } +} + +function loadCacheSync(cachePath) { + if (!fs.existsSync(cachePath)) { + return Object.create(null); + } + + try { + return JSON.parse(fs.readFileSync(cachePath)); + } catch (e) { + if (e instanceof SyntaxError) { + console.warn('Unable to parse cache file. Will clear and continue.'); + try { + fs.unlinkSync(cachePath); + } catch (err) { + // Someone else might've deleted it. + } + return Object.create(null); + } + throw e; + } +} + +module.exports = Cache; diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/DependencyGraphHelpers.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/DependencyGraphHelpers.js new file mode 100644 index 00000000..355c3ce8 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/DependencyGraphHelpers.js @@ -0,0 +1,57 @@ + /** + * 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. + * + * @flow + */ + +'use strict'; + +const path = require('path'); + +const NODE_MODULES = path.sep + 'node_modules' + path.sep; + +class DependencyGraphHelpers { + + _providesModuleNodeModules: Array; + _assetExts: Array; + + constructor({ providesModuleNodeModules, assetExts }: { + providesModuleNodeModules: Array, + assetExts: Array, + }) { + this._providesModuleNodeModules = providesModuleNodeModules; + this._assetExts = assetExts; + } + + isNodeModulesDir(file: string) { + const index = file.lastIndexOf(NODE_MODULES); + if (index === -1) { + return false; + } + + const parts = file.substr(index + 14).split(path.sep); + const dirs = this._providesModuleNodeModules; + for (let i = 0; i < dirs.length; i++) { + if (parts.indexOf(dirs[i]) > -1) { + return false; + } + } + + return true; + } + + isAssetFile(file: string) { + return this._assetExts.indexOf(this.extname(file)) !== -1; + } + + extname(name: string) { + return path.extname(name).substr(1); + } +} + +module.exports = DependencyGraphHelpers; diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/HasteMap.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/HasteMap.js new file mode 100644 index 00000000..975cd305 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/HasteMap.js @@ -0,0 +1,171 @@ + /** + * 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'; + +const EventEmitter = require('events'); + +const getPlatformExtension = require('../lib/getPlatformExtension'); +const path = require('path'); +const throat = require('throat'); + +const GENERIC_PLATFORM = 'generic'; +const NATIVE_PLATFORM = 'native'; +const PACKAGE_JSON = path.sep + 'package.json'; + +class HasteMap extends EventEmitter { + constructor({ + extensions, + files, + moduleCache, + preferNativePlatform, + helpers, + platforms, + }) { + super(); + this._extensions = extensions; + this._files = files; + this._helpers = helpers; + this._moduleCache = moduleCache; + this._platforms = platforms; + this._preferNativePlatform = preferNativePlatform; + + this._processHastePackage = throat(1, this._processHastePackage.bind(this)); + this._processHasteModule = throat(1, this._processHasteModule.bind(this)); + } + + build() { + this._map = Object.create(null); + const promises = []; + this._files.forEach(filePath => { + if (!this._helpers.isNodeModulesDir(filePath)) { + if (this._extensions.indexOf(path.extname(filePath).substr(1)) !== -1) { + promises.push(this._processHasteModule(filePath)); + } + if (filePath.endsWith(PACKAGE_JSON)) { + promises.push(this._processHastePackage(filePath)); + } + } + }); + return Promise.all(promises).then(() => this._map); + } + + processFileChange(type, absPath) { + return Promise.resolve().then(() => { + /*eslint no-labels: 0 */ + let invalidated; + if (type === 'delete' || type === 'change') { + loop: for (const name in this._map) { + const modulesMap = this._map[name]; + for (const platform in modulesMap) { + const module = modulesMap[platform]; + if (module.path === absPath) { + delete modulesMap[platform]; + invalidated = name; + break loop; + } + } + } + + if (type === 'delete') { + if (invalidated) { + this.emit('change'); + } + return null; + } + } + + if (type !== 'delete' && this._extensions.indexOf(this._helpers.extname(absPath)) !== -1) { + if (path.basename(absPath) === 'package.json') { + return this._processHastePackage(absPath, invalidated); + } else { + return this._processHasteModule(absPath, invalidated); + } + } + }); + } + + getModule(name, platform = null) { + const modulesMap = this._map[name]; + if (modulesMap == null) { + return null; + } + + // If platform is 'ios', we prefer .ios.js to .native.js which we prefer to + // a plain .js file. + let module; + if (module == null && platform != null) { + module = modulesMap[platform]; + } + if (module == null && this._preferNativePlatform) { + module = modulesMap[NATIVE_PLATFORM]; + } + if (module == null) { + module = modulesMap[GENERIC_PLATFORM]; + } + return module; + } + + _processHasteModule(file, previousName) { + const module = this._moduleCache.getModule(file); + return module.isHaste().then( + isHaste => isHaste && module.getName() + .then(name => { + const result = this._updateHasteMap(name, module); + if (previousName && name !== previousName) { + this.emit('change'); + } + return result; + }) + ); + } + + _processHastePackage(file, previousName) { + const p = this._moduleCache.getPackage(file); + return p.isHaste() + .then(isHaste => isHaste && p.getName() + .then(name => { + const result = this._updateHasteMap(name, p); + if (previousName && name !== previousName) { + this.emit('change'); + } + return result; + })) + .catch(e => { + if (e instanceof SyntaxError) { + // Malformed package.json. + return; + } + throw e; + }); + } + + _updateHasteMap(name, mod) { + if (this._map[name] == null) { + this._map[name] = Object.create(null); + } + + const moduleMap = this._map[name]; + const modulePlatform = getPlatformExtension(mod.path, this._platforms) || GENERIC_PLATFORM; + const existingModule = moduleMap[modulePlatform]; + + if (existingModule && existingModule.path !== mod.path) { + throw new Error( + `@providesModule naming collision:\n` + + ` Duplicate module name: ${name}\n` + + ` Paths: ${mod.path} collides with ${existingModule.path}\n\n` + + 'This error is caused by a @providesModule declaration ' + + 'with the same name across two different files.' + ); + } + + moduleMap[modulePlatform] = mod; + } +} + +module.exports = HasteMap; diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionRequest.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionRequest.js new file mode 100644 index 00000000..3abacbf9 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionRequest.js @@ -0,0 +1,522 @@ +/** + * 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. + * + * @flow + */ +'use strict'; + +const AsyncTaskGroup = require('../lib/AsyncTaskGroup'); +const MapWithDefaults = require('../lib/MapWithDefaults'); + +const debug = require('debug')('RNP:DependencyGraph'); +const util = require('util'); +const path = require('path'); +const realPath = require('path'); +const isAbsolutePath = require('absolute-path'); +const getAssetDataFromName = require('../lib/getAssetDataFromName'); + +import type {HasteFS} from '../types'; +import type DependencyGraphHelpers from './DependencyGraphHelpers'; +import type HasteMap from './HasteMap'; +import type Module from '../Module'; +import type ModuleCache from '../ModuleCache'; +import type ResolutionResponse from './ResolutionResponse'; + +type DirExistsFn = (filePath: string) => boolean; + +type Options = { + dirExists: DirExistsFn, + entryPath: string, + extraNodeModules: ?Object, + hasteFS: HasteFS, + hasteMap: HasteMap, + helpers: DependencyGraphHelpers, + // TODO(cpojer): Remove 'any' type. This is used for ModuleGraph/node-haste + moduleCache: ModuleCache | any, + platform: string, + platforms: Set, + preferNativePlatform: boolean, +}; + +class ResolutionRequest { + _dirExists: DirExistsFn; + _entryPath: string; + _extraNodeModules: ?Object; + _hasteFS: HasteFS; + _hasteMap: HasteMap; + _helpers: DependencyGraphHelpers; + _immediateResolutionCache: {[key: string]: string}; + _moduleCache: ModuleCache; + _platform: string; + _platforms: Set; + _preferNativePlatform: boolean; + static emptyModule: string; + + constructor({ + dirExists, + entryPath, + extraNodeModules, + hasteFS, + hasteMap, + helpers, + moduleCache, + platform, + platforms, + preferNativePlatform, + }: Options) { + this._dirExists = dirExists; + this._entryPath = entryPath; + this._extraNodeModules = extraNodeModules; + this._hasteFS = hasteFS; + this._hasteMap = hasteMap; + this._helpers = helpers; + this._moduleCache = moduleCache; + this._platform = platform; + this._platforms = platforms; + this._preferNativePlatform = preferNativePlatform; + this._resetResolutionCache(); + } + + _tryResolve(action, secondaryAction) { + return action().catch((error) => { + if (error.type !== 'UnableToResolveError') { + throw error; + } + return secondaryAction(); + }); + } + + // TODO(cpojer): Remove 'any' type. This is used for ModuleGraph/node-haste + resolveDependency(fromModule: Module | any, toModuleName: string) { + const resHash = resolutionHash(fromModule.path, toModuleName); + + if (this._immediateResolutionCache[resHash]) { + return Promise.resolve(this._immediateResolutionCache[resHash]); + } + + const cacheResult = (result) => { + this._immediateResolutionCache[resHash] = result; + return result; + }; + + if (!this._helpers.isNodeModulesDir(fromModule.path) + && !(isRelativeImport(toModuleName) || isAbsolutePath(toModuleName))) { + return this._tryResolve( + () => this._resolveHasteDependency(fromModule, toModuleName), + () => this._resolveNodeDependency(fromModule, toModuleName) + ).then(cacheResult); + } + + return this._resolveNodeDependency(fromModule, toModuleName) + .then(cacheResult); + } + + getOrderedDependencies({ + response, + transformOptions, + onProgress, + recursive = true, + }: { + response: ResolutionResponse, + transformOptions: Object, + onProgress?: ?(finishedModules: number, totalModules: number) => mixed, + recursive: boolean, + }) { + const entry = this._moduleCache.getModule(this._entryPath); + + response.pushDependency(entry); + let totalModules = 1; + let finishedModules = 0; + + const resolveDependencies = module => + module.getDependencies(transformOptions) + .then(dependencyNames => + Promise.all( + dependencyNames.map(name => this.resolveDependency(module, name)) + ).then(dependencies => [dependencyNames, dependencies]) + ); + + const collectedDependencies = new MapWithDefaults(module => collect(module)); + const crawlDependencies = (mod, [depNames, dependencies]) => { + const filteredPairs = []; + + dependencies.forEach((modDep, i) => { + const name = depNames[i]; + if (modDep == null) { + debug( + 'WARNING: Cannot find required module `%s` from module `%s`', + name, + mod.path + ); + return false; + } + return filteredPairs.push([name, modDep]); + }); + + response.setResolvedDependencyPairs(mod, filteredPairs); + + const dependencyModules = filteredPairs.map(([, m]) => m); + const newDependencies = + dependencyModules.filter(m => !collectedDependencies.has(m)); + + if (onProgress) { + finishedModules += 1; + totalModules += newDependencies.length; + onProgress(finishedModules, totalModules); + } + + if (recursive) { + // doesn't block the return of this function invocation, but defers + // the resulution of collectionsInProgress.done.then(...) + dependencyModules + .forEach(dependency => collectedDependencies.get(dependency)); + } + return dependencyModules; + }; + + const collectionsInProgress = new AsyncTaskGroup(); + function collect(module) { + collectionsInProgress.start(module); + const result = resolveDependencies(module) + .then(deps => crawlDependencies(module, deps)); + const end = () => collectionsInProgress.end(module); + result.then(end, end); + return result; + } + + return Promise.all([ + // kicks off recursive dependency discovery, but doesn't block until it's done + collectedDependencies.get(entry), + + // resolves when there are no more modules resolving dependencies + collectionsInProgress.done, + ]).then(([rootDependencies]) => { + return Promise.all( + Array.from(collectedDependencies, resolveKeyWithPromise) + ).then(moduleToDependenciesPairs => + [rootDependencies, new MapWithDefaults(() => [], moduleToDependenciesPairs)] + ); + }).then(([rootDependencies, moduleDependencies]) => { + // serialize dependencies, and make sure that every single one is only + // included once + const seen = new Set([entry]); + function traverse(dependencies) { + dependencies.forEach(dependency => { + if (seen.has(dependency)) { return; } + + seen.add(dependency); + response.pushDependency(dependency); + traverse(moduleDependencies.get(dependency)); + }); + } + + traverse(rootDependencies); + }); + } + + _resolveHasteDependency(fromModule, toModuleName) { + toModuleName = normalizePath(toModuleName); + + let p = fromModule.getPackage(); + if (p) { + p = p.redirectRequire(toModuleName); + } else { + p = Promise.resolve(toModuleName); + } + + return p.then((realModuleName) => { + let dep = this._hasteMap.getModule(realModuleName, this._platform); + if (dep && dep.type === 'Module') { + return dep; + } + + let packageName = realModuleName; + while (packageName && packageName !== '.') { + dep = this._hasteMap.getModule(packageName, this._platform); + if (dep && dep.type === 'Package') { + break; + } + packageName = path.dirname(packageName); + } + + if (dep && dep.type === 'Package') { + const potentialModulePath = path.join( + dep.root, + path.relative(packageName, realModuleName) + ); + return this._tryResolve( + () => this._loadAsFile( + potentialModulePath, + fromModule, + toModuleName, + ), + () => this._loadAsDir(potentialModulePath, fromModule, toModuleName), + ); + } + + throw new UnableToResolveError( + fromModule, + toModuleName, + 'Unable to resolve dependency', + ); + }); + } + + _redirectRequire(fromModule, modulePath) { + return Promise.resolve(fromModule.getPackage()).then(p => { + if (p) { + return p.redirectRequire(modulePath); + } + return modulePath; + }); + } + + _resolveFileOrDir(fromModule, toModuleName) { + const potentialModulePath = isAbsolutePath(toModuleName) ? + toModuleName : + path.join(path.dirname(fromModule.path), toModuleName); + + return this._redirectRequire(fromModule, potentialModulePath).then( + realModuleName => { + if (realModuleName === false) { + return this._loadAsFile( + ResolutionRequest.emptyModule, + fromModule, + toModuleName, + ); + } + + return this._tryResolve( + () => this._loadAsFile(realModuleName, fromModule, toModuleName), + () => this._loadAsDir(realModuleName, fromModule, toModuleName) + ); + } + ); + } + + _resolveNodeDependency(fromModule, toModuleName) { + if (isRelativeImport(toModuleName) || isAbsolutePath(toModuleName)) { + return this._resolveFileOrDir(fromModule, toModuleName); + } else { + return this._redirectRequire(fromModule, toModuleName).then( + realModuleName => { + // exclude + if (realModuleName === false) { + return this._loadAsFile( + ResolutionRequest.emptyModule, + fromModule, + toModuleName, + ); + } + + if (isRelativeImport(realModuleName) || isAbsolutePath(realModuleName)) { + // derive absolute path /.../node_modules/fromModuleDir/realModuleName + const fromModuleParentIdx = fromModule.path.lastIndexOf('node_modules/') + 13; + const fromModuleDir = fromModule.path.slice( + 0, + fromModule.path.indexOf('/', fromModuleParentIdx), + ); + const absPath = path.join(fromModuleDir, realModuleName); + return this._resolveFileOrDir(fromModule, absPath); + } + + const searchQueue = []; + for (let currDir = path.dirname(fromModule.path); + currDir !== '.' && currDir !== realPath.parse(fromModule.path).root; + currDir = path.dirname(currDir)) { + const searchPath = path.join(currDir, 'node_modules'); + if (this._dirExists(searchPath)) { + searchQueue.push( + path.join(searchPath, realModuleName) + ); + } + } + + if (this._extraNodeModules) { + const {_extraNodeModules} = this; + const bits = toModuleName.split('/'); + const packageName = bits[0]; + if (_extraNodeModules[packageName]) { + bits[0] = _extraNodeModules[packageName]; + searchQueue.push(path.join.apply(path, bits)); + } + } + + let p = Promise.reject(new UnableToResolveError(fromModule, toModuleName)); + searchQueue.forEach(potentialModulePath => { + p = this._tryResolve( + () => this._tryResolve( + () => p, + () => this._loadAsFile(potentialModulePath, fromModule, toModuleName), + ), + () => this._loadAsDir(potentialModulePath, fromModule, toModuleName) + ); + }); + + return p.catch(error => { + if (error.type !== 'UnableToResolveError') { + throw error; + } + const hint = searchQueue.length ? ' or in these directories:' : ''; + throw new UnableToResolveError( + fromModule, + toModuleName, + `Module does not exist in the module map${hint}\n` + + searchQueue.map(searchPath => ` ${path.dirname(searchPath)}\n`).join(', ') + '\n' + + `This might be related to https://github.com/facebook/react-native/issues/4968\n` + + `To resolve try the following:\n` + + ` 1. Clear watchman watches: \`watchman watch-del-all\`.\n` + + ` 2. Delete the \`node_modules\` folder: \`rm -rf node_modules && npm install\`.\n` + + ' 3. Reset packager cache: `rm -fr $TMPDIR/react-*` or `npm start --reset-cache`.' + ); + }); + }); + } + } + + _loadAsFile(potentialModulePath, fromModule, toModule) { + return Promise.resolve().then(() => { + if (this._helpers.isAssetFile(potentialModulePath)) { + let dirname = path.dirname(potentialModulePath); + if (!this._dirExists(dirname)) { + throw new UnableToResolveError( + fromModule, + toModule, + `Directory ${dirname} doesn't exist`, + ); + } + + const {name, type} = getAssetDataFromName(potentialModulePath, this._platforms); + + let pattern = name + '(@[\\d\\.]+x)?'; + if (this._platform != null) { + pattern += '(\\.' + this._platform + ')?'; + } + pattern += '\\.' + type; + + // Escape backslashes in the path to be able to use it in the regex + if (path.sep === '\\') { + dirname = dirname.replace(/\\/g, '\\\\'); + } + + // We arbitrarly grab the first one, because scale selection + // will happen somewhere + const [assetFile] = this._hasteFS.matchFiles( + new RegExp(dirname + '(\/|\\\\)' + pattern) + ); + if (assetFile) { + return this._moduleCache.getAssetModule(assetFile); + } + } + + let file; + if (this._hasteFS.exists(potentialModulePath)) { + file = potentialModulePath; + } else if (this._platform != null && + this._hasteFS.exists(potentialModulePath + '.' + this._platform + '.js')) { + file = potentialModulePath + '.' + this._platform + '.js'; + } else if (this._preferNativePlatform && + this._hasteFS.exists(potentialModulePath + '.native.js')) { + file = potentialModulePath + '.native.js'; + } else if (this._hasteFS.exists(potentialModulePath + '.js')) { + file = potentialModulePath + '.js'; + } else if (this._hasteFS.exists(potentialModulePath + '.json')) { + file = potentialModulePath + '.json'; + } else { + throw new UnableToResolveError( + fromModule, + toModule, + `File ${potentialModulePath} doesn't exist`, + ); + } + + return this._moduleCache.getModule(file); + }); + } + + _loadAsDir(potentialDirPath, fromModule, toModule) { + return Promise.resolve().then(() => { + if (!this._dirExists(potentialDirPath)) { + throw new UnableToResolveError( + fromModule, + toModule, + `Directory ${potentialDirPath} doesn't exist`, + ); + } + + const packageJsonPath = path.join(potentialDirPath, 'package.json'); + if (this._hasteFS.exists(packageJsonPath)) { + return this._moduleCache.getPackage(packageJsonPath) + .getMain().then( + (main) => this._tryResolve( + () => this._loadAsFile(main, fromModule, toModule), + () => this._loadAsDir(main, fromModule, toModule) + ) + ); + } + + return this._loadAsFile( + path.join(potentialDirPath, 'index'), + fromModule, + toModule, + ); + }); + } + + _resetResolutionCache() { + this._immediateResolutionCache = Object.create(null); + } + +} + + +function resolutionHash(modulePath, depName) { + return `${path.resolve(modulePath)}:${depName}`; +} + +class UnableToResolveError extends Error { + type: string; + from: string; + to: string; + + constructor(fromModule, toModule, message) { + super(); + this.from = fromModule.path; + this.to = toModule; + this.message = util.format( + 'Unable to resolve module %s from %s: %s', + toModule, + fromModule.path, + message, + ); + this.type = this.name = 'UnableToResolveError'; + } + +} + +function normalizePath(modulePath) { + if (path.sep === '/') { + modulePath = path.normalize(modulePath); + } else if (path.posix) { + modulePath = path.posix.normalize(modulePath); + } + + return modulePath.replace(/\/$/, ''); +} + +function resolveKeyWithPromise([key, promise]) { + return promise.then(value => [key, value]); +} + +function isRelativeImport(filePath) { + return /^[.][.]?(?:[/]|$)/.test(filePath); +} + +ResolutionRequest.emptyModule = require.resolve('./assets/empty-module.js'); + +module.exports = ResolutionRequest; diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js new file mode 100644 index 00000000..8ed5d91f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js @@ -0,0 +1,132 @@ + /** + * 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. + * + * @flow + */ + +'use strict'; + +import Module from '../Module'; + +import type {Options as TransformOptions} from '../../JSTransformer/worker/worker'; + +const NO_OPTIONS = {}; + +class ResolutionResponse { + + transformOptions: TransformOptions; + dependencies: Array; + mainModuleId: ?(number | string); + mocks: mixed; + numPrependedDependencies: number; + + // This is monkey-patched from Resolver. + getModuleId: ?() => number; + + _mappings: {}; + _finalized: boolean; + _mainModule: ?Module; + + constructor({transformOptions}: {transformOptions: TransformOptions}) { + this.transformOptions = transformOptions; + this.dependencies = []; + this.mainModuleId = null; + this.mocks = null; + this.numPrependedDependencies = 0; + this._mappings = Object.create(null); + this._finalized = false; + } + + copy(properties: { + dependencies?: Array, + mainModuleId?: number, + mocks?: mixed, + }): ResolutionResponse { + const { + dependencies = this.dependencies, + mainModuleId = this.mainModuleId, + mocks = this.mocks, + } = properties; + + const numPrependedDependencies = dependencies === this.dependencies + ? this.numPrependedDependencies : 0; + + /* $FlowFixMe: Flow doesn't like Object.assign on class-made objects. */ + return Object.assign( + new this.constructor({transformOptions: this.transformOptions}), + this, + { + dependencies, + mainModuleId, + mocks, + numPrependedDependencies, + }, + ); + } + + _assertNotFinalized() { + if (this._finalized) { + throw new Error('Attempted to mutate finalized response.'); + } + } + + _assertFinalized() { + if (!this._finalized) { + throw new Error('Attempted to access unfinalized response.'); + } + } + + finalize(): ResolutionResponse { + /* $FlowFixMe: _mainModule is not initialized in the constructor. */ + return this._mainModule.getName().then(id => { + this.mainModuleId = id; + this._finalized = true; + return this; + }); + } + + pushDependency(module: Module) { + this._assertNotFinalized(); + if (this.dependencies.length === 0) { + this._mainModule = module; + } + + this.dependencies.push(module); + } + + prependDependency(module: Module) { + this._assertNotFinalized(); + this.dependencies.unshift(module); + this.numPrependedDependencies += 1; + } + + setResolvedDependencyPairs( + module: Module, + pairs: mixed, + options: {ignoreFinalized?: boolean} = NO_OPTIONS, + ) { + if (!options.ignoreFinalized) { + this._assertNotFinalized(); + } + const hash = module.hash(); + if (this._mappings[hash] == null) { + this._mappings[hash] = pairs; + } + } + + setMocks(mocks: mixed) { + this.mocks = mocks; + } + + getResolvedDependencyPairs(module: Module) { + this._assertFinalized(); + return this._mappings[module.hash()]; + } +} + +module.exports = ResolutionResponse; diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/assets/empty-module.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/assets/empty-module.js new file mode 100644 index 00000000..1c98b337 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/assets/empty-module.js @@ -0,0 +1,8 @@ +/** + * 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. + */ diff --git a/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/docblock.js b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/docblock.js new file mode 100644 index 00000000..d710112a --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/DependencyGraph/docblock.js @@ -0,0 +1,83 @@ +/** + * 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'; + +var docblockRe = /^\s*(\/\*\*(.|\r?\n)*?\*\/)/; + +var ltrimRe = /^\s*/; +/** + * @param {String} contents + * @return {String} + */ +function extract(contents) { + var match = contents.match(docblockRe); + if (match) { + return match[0].replace(ltrimRe, '') || ''; + } + return ''; +} + + +var commentStartRe = /^\/\*\*/; +var commentEndRe = /\*\/$/; +var wsRe = /[\t ]+/g; +var stringStartRe = /(\r?\n|^) *\*/g; +var multilineRe = + /(?:^|\r?\n) *(@[^\r\n]*?) *\r?\n *([^@\r\n\s][^@\r\n]+?) *\r?\n/g; +var propertyRe = /(?:^|\r?\n) *@(\S+) *([^\r\n]*)/g; + +/** + * @param {String} contents + * @return {Array} + */ +function parse(docblock) { + docblock = docblock + .replace(commentStartRe, '') + .replace(commentEndRe, '') + .replace(wsRe, ' ') + .replace(stringStartRe, '$1'); + + // Normalize multi-line directives + var prev = ''; + while (prev !== docblock) { + prev = docblock; + docblock = docblock.replace(multilineRe, '\n$1 $2\n'); + } + docblock = docblock.trim(); + + var result = []; + var match; + while ((match = propertyRe.exec(docblock))) { + result.push([match[1], match[2]]); + } + + return result; +} + +/** + * Same as parse but returns an object of prop: value instead of array of paris + * If a property appers more than once the last one will be returned + * + * @param {String} contents + * @return {Object} + */ +function parseAsObject(docblock) { + var pairs = parse(docblock); + var result = {}; + for (var i = 0; i < pairs.length; i++) { + result[pairs[i][0]] = pairs[i][1]; + } + return result; +} + + +exports.extract = extract; +exports.parse = parse; +exports.parseAsObject = parseAsObject; diff --git a/packages/metro-bundler/react-packager/src/node-haste/Module.js b/packages/metro-bundler/react-packager/src/node-haste/Module.js new file mode 100644 index 00000000..69945c1d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Module.js @@ -0,0 +1,384 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const TransformCache = require('../lib/TransformCache'); + +const crypto = require('crypto'); +const docblock = require('./DependencyGraph/docblock'); +const fs = require('fs'); +const invariant = require('fbjs/lib/invariant'); +const isAbsolutePath = require('absolute-path'); +const jsonStableStringify = require('json-stable-stringify'); + +const {join: joinPath, relative: relativePath, extname} = require('path'); + +import type {TransformedCode, Options as TransformOptions} from '../JSTransformer/worker/worker'; +import type GlobalTransformCache from '../lib/GlobalTransformCache'; +import type {SourceMap} from '../lib/SourceMap'; +import type {ReadTransformProps} from '../lib/TransformCache'; +import type {Reporter} from '../lib/reporting'; +import type Cache from './Cache'; +import type DependencyGraphHelpers from './DependencyGraph/DependencyGraphHelpers'; +import type ModuleCache from './ModuleCache'; + +export type ReadResult = { + code: string, + dependencies?: ?Array, + dependencyOffsets?: ?Array, + map?: ?SourceMap, + source: string, +}; + +export type TransformCode = ( + module: Module, + sourceCode: string, + transformOptions: TransformOptions, +) => Promise; + +export type Options = { + resetCache?: boolean, + cacheTransformResults?: boolean, +}; + +export type ConstructorArgs = { + cache: Cache, + depGraphHelpers: DependencyGraphHelpers, + globalTransformCache: ?GlobalTransformCache, + file: string, + moduleCache: ModuleCache, + options: Options, + reporter: Reporter, + transformCacheKey: ?string, + transformCode: ?TransformCode, +}; + +class Module { + + path: string; + type: string; + + _moduleCache: ModuleCache; + _cache: Cache; + _transformCode: ?TransformCode; + _transformCacheKey: ?string; + _depGraphHelpers: DependencyGraphHelpers; + _options: Options; + _reporter: Reporter; + _globalCache: ?GlobalTransformCache; + + _docBlock: Promise<{id?: string, moduleDocBlock: {[key: string]: mixed}}>; + _readSourceCodePromise: Promise; + _readPromises: Map>; + + constructor({ + cache, + depGraphHelpers, + file, + globalTransformCache, + moduleCache, + options, + reporter, + transformCacheKey, + transformCode, + }: ConstructorArgs) { + if (!isAbsolutePath(file)) { + throw new Error('Expected file to be absolute path but got ' + file); + } + + this.path = file; + this.type = 'Module'; + + this._moduleCache = moduleCache; + this._cache = cache; + this._transformCode = transformCode; + this._transformCacheKey = transformCacheKey; + invariant( + transformCode == null || transformCacheKey != null, + 'missing transform cache key', + ); + this._depGraphHelpers = depGraphHelpers; + this._options = options || {}; + this._reporter = reporter; + this._globalCache = globalTransformCache; + + this._readPromises = new Map(); + } + + isHaste(): Promise { + return this._cache.get( + this.path, + 'isHaste', + () => this._readDocBlock().then(({id}) => !!id) + ); + } + + getCode(transformOptions: TransformOptions) { + return this.read(transformOptions).then(({code}) => code); + } + + getMap(transformOptions: TransformOptions) { + return this.read(transformOptions).then(({map}) => map); + } + + getName(): Promise { + return this._cache.get( + this.path, + 'name', + () => this._readDocBlock().then(({id}) => { + if (id) { + return id; + } + + const p = this.getPackage(); + + if (!p) { + // Name is full path + return this.path; + } + + return p.getName() + .then(name => { + if (!name) { + return this.path; + } + + return joinPath(name, relativePath(p.root, this.path)).replace(/\\/g, '/'); + }); + }) + ); + } + + getPackage() { + return this._moduleCache.getPackageForModule(this); + } + + getDependencies(transformOptions: TransformOptions) { + return this.read(transformOptions).then(({dependencies}) => dependencies); + } + + /** + * We don't need to invalidate the TranformCache itself because it guarantees + * itself that if a source code changed we won't return the cached transformed + * code. + */ + invalidate() { + this._cache.invalidate(this.path); + this._readPromises.clear(); + } + + _parseDocBlock(docBlock) { + // Extract an id for the module if it's using @providesModule syntax + // and if it's NOT in node_modules (and not a whitelisted node_module). + // This handles the case where a project may have a dep that has @providesModule + // docblock comments, but doesn't want it to conflict with whitelisted @providesModule + // modules, such as react-haste, fbjs-haste, or react-native or with non-dependency, + // project-specific code that is using @providesModule. + const moduleDocBlock = docblock.parseAsObject(docBlock); + const provides = moduleDocBlock.providesModule || moduleDocBlock.provides; + + const id = provides && !this._depGraphHelpers.isNodeModulesDir(this.path) + ? /^\S+/.exec(provides)[0] + : undefined; + return {id, moduleDocBlock}; + } + + _readSourceCode() { + if (!this._readSourceCodePromise) { + this._readSourceCodePromise = new Promise( + resolve => resolve(fs.readFileSync(this.path, 'utf8')) + ); + } + return this._readSourceCodePromise; + } + + _readDocBlock() { + if (!this._docBlock) { + this._docBlock = this._readSourceCode() + .then(docBlock => this._parseDocBlock(docBlock)); + } + return this._docBlock; + } + + /** + * To what we read from the cache or worker, we need to add id and source. + */ + _finalizeReadResult( + source: string, + id?: string, + extern: boolean, + result: TransformedCode, + ): ReadResult { + if (this._options.cacheTransformResults === false) { + const {dependencies} = result; + /* $FlowFixMe: this code path is dead, remove. */ + return {dependencies}; + } + return {...result, id, source}; + } + + _transformCodeForCallback( + cacheProps: ReadTransformProps, + callback: (error: ?Error, result: ?TransformedCode) => void, + ) { + const {_transformCode} = this; + invariant(_transformCode != null, 'missing code transform funtion'); + const {sourceCode, transformOptions} = cacheProps; + return _transformCode(this, sourceCode, transformOptions).then( + freshResult => process.nextTick(callback, undefined, freshResult), + error => process.nextTick(callback, error), + ); + } + + _transformAndStoreCodeGlobally( + cacheProps: ReadTransformProps, + globalCache: GlobalTransformCache, + callback: (error: ?Error, result: ?TransformedCode) => void, + ) { + this._transformCodeForCallback( + cacheProps, + (transformError, transformResult) => { + if (transformError != null) { + callback(transformError); + return; + } + invariant( + transformResult != null, + 'Inconsistent state: there is no error, but no results either.', + ); + globalCache.store(cacheProps, transformResult); + callback(undefined, transformResult); + }, + ); + } + + _getTransformedCode( + cacheProps: ReadTransformProps, + callback: (error: ?Error, result: ?TransformedCode) => void, + ) { + const {_globalCache} = this; + if (_globalCache == null) { + this._transformCodeForCallback(cacheProps, callback); + return; + } + _globalCache.fetch(cacheProps, (globalCacheError, globalCachedResult) => { + if (globalCacheError) { + callback(globalCacheError); + return; + } + if (globalCachedResult == null) { + this._transformAndStoreCodeGlobally(cacheProps, _globalCache, callback); + return; + } + callback(undefined, globalCachedResult); + }); + } + + _getAndCacheTransformedCode( + cacheProps: ReadTransformProps, + callback: (error: ?Error, result: ?TransformedCode) => void, + ) { + this._getTransformedCode(cacheProps, (error, result) => { + if (error) { + callback(error); + return; + } + invariant(result != null, 'missing result'); + TransformCache.writeSync({...cacheProps, result}); + callback(undefined, result); + }); + } + + /** + * Read everything about a module: source code, transformed code, + * dependencies, etc. The overall process is to read the cache first, and if + * it's a miss, we let the worker write to the cache and read it again. + */ + read(transformOptions: TransformOptions): Promise { + const key = stableObjectHash(transformOptions || {}); + const promise = this._readPromises.get(key); + if (promise != null) { + return promise; + } + const freshPromise = Promise.all([ + this._readSourceCode(), + this._readDocBlock(), + ]).then(([sourceCode, {id, moduleDocBlock}]) => { + // Ignore requires in JSON files or generated code. An example of this + // is prebuilt files like the SourceMap library. + const extern = this.isJSON() || 'extern' in moduleDocBlock; + if (extern) { + transformOptions = {...transformOptions, extern}; + } + const transformCacheKey = this._transformCacheKey; + invariant(transformCacheKey != null, 'missing transform cache key'); + const cacheProps = { + filePath: this.path, + sourceCode, + transformCacheKey, + transformOptions, + cacheOptions: this._options, + }; + const cachedResult = TransformCache.readSync(cacheProps); + if (cachedResult) { + return Promise.resolve(this._finalizeReadResult(sourceCode, id, extern, cachedResult)); + } + return new Promise((resolve, reject) => { + this._getAndCacheTransformedCode( + cacheProps, + (transformError, freshResult) => { + if (transformError) { + reject(transformError); + return; + } + invariant(freshResult != null, 'inconsistent state'); + resolve(this._finalizeReadResult(sourceCode, id, extern, freshResult)); + }, + ); + }); + }); + this._readPromises.set(key, freshPromise); + return freshPromise; + } + + hash() { + return `Module : ${this.path}`; + } + + isJSON() { + return extname(this.path) === '.json'; + } + + isAsset() { + return false; + } + + isPolyfill() { + return false; + } +} + +// use weak map to speed up hash creation of known objects +const knownHashes = new WeakMap(); +function stableObjectHash(object) { + let digest = knownHashes.get(object); + if (!digest) { + digest = crypto.createHash('md5') + .update(jsonStableStringify(object)) + .digest('base64'); + knownHashes.set(object, digest); + } + + return digest; +} + +module.exports = Module; diff --git a/packages/metro-bundler/react-packager/src/node-haste/ModuleCache.js b/packages/metro-bundler/react-packager/src/node-haste/ModuleCache.js new file mode 100644 index 00000000..75ad54b2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/ModuleCache.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2013-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. + * + * @flow + */ + +'use strict'; + +const AssetModule = require('./AssetModule'); +const Module = require('./Module'); +const Package = require('./Package'); +const Polyfill = require('./Polyfill'); + +import type GlobalTransformCache from '../lib/GlobalTransformCache'; +import type {Reporter} from '../lib/reporting'; +import type Cache from './Cache'; +import type DependencyGraphHelpers from './DependencyGraph/DependencyGraphHelpers'; +import type {TransformCode, Options as ModuleOptions} from './Module'; + +type GetClosestPackageFn = (filePath: string) => ?string; + +class ModuleCache { + + _assetDependencies: Array; + _cache: Cache; + _depGraphHelpers: DependencyGraphHelpers; + _getClosestPackage: GetClosestPackageFn; + _globalTransformCache: ?GlobalTransformCache; + _moduleCache: {[filePath: string]: Module}; + _moduleOptions: ModuleOptions; + _packageCache: {[filePath: string]: Package}; + _packageModuleMap: WeakMap; + _platforms: Set; + _transformCacheKey: string; + _transformCode: TransformCode; + _reporter: Reporter; + + constructor({ + assetDependencies, + cache, + depGraphHelpers, + extractRequires, + getClosestPackage, + globalTransformCache, + moduleOptions, + reporter, + transformCacheKey, + transformCode, + }: { + assetDependencies: Array, + cache: Cache, + depGraphHelpers: DependencyGraphHelpers, + getClosestPackage: GetClosestPackageFn, + globalTransformCache: ?GlobalTransformCache, + moduleOptions: ModuleOptions, + reporter: Reporter, + transformCacheKey: string, + transformCode: TransformCode, + }, platforms: Set) { + this._assetDependencies = assetDependencies; + this._getClosestPackage = getClosestPackage; + this._globalTransformCache = globalTransformCache; + this._cache = cache; + this._depGraphHelpers = depGraphHelpers; + this._moduleCache = Object.create(null); + this._moduleOptions = moduleOptions; + this._packageCache = Object.create(null); + this._packageModuleMap = new WeakMap(); + this._platforms = platforms; + this._transformCacheKey = transformCacheKey; + this._transformCode = transformCode; + this._reporter = reporter; + } + + getModule(filePath: string) { + if (!this._moduleCache[filePath]) { + this._moduleCache[filePath] = new Module({ + cache: this._cache, + depGraphHelpers: this._depGraphHelpers, + file: filePath, + globalTransformCache: this._globalTransformCache, + moduleCache: this, + options: this._moduleOptions, + reporter: this._reporter, + transformCacheKey: this._transformCacheKey, + transformCode: this._transformCode, + }); + } + return this._moduleCache[filePath]; + } + + getAllModules() { + return this._moduleCache; + } + + getAssetModule(filePath: string) { + if (!this._moduleCache[filePath]) { + /* $FlowFixMe: missing options. This is because this is an incorrect OOP + * design in the first place: AssetModule, being simpler than a normal + * Module, should not inherit the Module class. */ + this._moduleCache[filePath] = new AssetModule({ + file: filePath, + moduleCache: this, + cache: this._cache, + dependencies: this._assetDependencies, + }, this._platforms); + } + return this._moduleCache[filePath]; + } + + getPackage(filePath: string) { + if (!this._packageCache[filePath]) { + this._packageCache[filePath] = new Package({ + file: filePath, + cache: this._cache, + }); + } + return this._packageCache[filePath]; + } + + getPackageForModule(module: Module): ?Package { + if (this._packageModuleMap.has(module)) { + const packagePath = this._packageModuleMap.get(module); + // $FlowFixMe(>=0.37.0) + if (this._packageCache[packagePath]) { + return this._packageCache[packagePath]; + } else { + this._packageModuleMap.delete(module); + } + } + + const packagePath = this._getClosestPackage(module.path); + if (!packagePath) { + return null; + } + + this._packageModuleMap.set(module, packagePath); + return this.getPackage(packagePath); + } + + createPolyfill({file}: {file: string}) { + /* $FlowFixMe: there are missing arguments. */ + return new Polyfill({ + file, + cache: this._cache, + depGraphHelpers: this._depGraphHelpers, + moduleCache: this, + transformCode: this._transformCode, + transformCacheKey: this._transformCacheKey, + }); + } + + processFileChange(type: string, filePath: string) { + if (this._moduleCache[filePath]) { + this._moduleCache[filePath].invalidate(); + delete this._moduleCache[filePath]; + } + if (this._packageCache[filePath]) { + this._packageCache[filePath].invalidate(); + delete this._packageCache[filePath]; + } + } +} + +module.exports = ModuleCache; diff --git a/packages/metro-bundler/react-packager/src/node-haste/Package.js b/packages/metro-bundler/react-packager/src/node-haste/Package.js new file mode 100644 index 00000000..e77f9c22 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Package.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2013-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. + * + * @flow + */ + +'use strict'; + +const fs = require('fs'); +const isAbsolutePath = require('absolute-path'); +const path = require('path'); + +import type Cache from './Cache'; + +class Package { + + path: string; + root: string; + type: string; + _cache: Cache; + + _reading: Promise<{ + name: string, + 'react-native': mixed, + browser: mixed, + main: ?string, + }>; + + constructor({file, cache}: { + file: string, + cache: Cache, + }) { + this.path = path.resolve(file); + this.root = path.dirname(this.path); + this.type = 'Package'; + this._cache = cache; + } + + getMain() { + return this.read().then(json => { + var replacements = getReplacements(json); + if (typeof replacements === 'string') { + return path.join(this.root, replacements); + } + + let main = json.main || 'index'; + + if (replacements && typeof replacements === 'object') { + main = replacements[main] || + replacements[main + '.js'] || + replacements[main + '.json'] || + replacements[main.replace(/(\.js|\.json)$/, '')] || + main; + } + + /* $FlowFixMe: `getReplacements` doesn't validate the return value. */ + return path.join(this.root, main); + }); + } + + isHaste() { + return this._cache.get(this.path, 'package-haste', () => + this.read().then(json => !!json.name) + ); + } + + getName(): Promise { + return this._cache.get(this.path, 'package-name', () => + this.read().then(json => json.name) + ); + } + + invalidate() { + this._cache.invalidate(this.path); + } + + redirectRequire(name: string) { + return this.read().then(json => { + var replacements = getReplacements(json); + + if (!replacements || typeof replacements !== 'object') { + return name; + } + + if (!isAbsolutePath(name)) { + const replacement = replacements[name]; + // support exclude with "someDependency": false + return replacement === false + ? false + : replacement || name; + } + + let relPath = './' + path.relative(this.root, name); + if (path.sep !== '/') { + relPath = relPath.replace(new RegExp('\\' + path.sep, 'g'), '/'); + } + + let redirect = replacements[relPath]; + + // false is a valid value + if (redirect == null) { + redirect = replacements[relPath + '.js']; + if (redirect == null) { + redirect = replacements[relPath + '.json']; + } + } + + // support exclude with "./someFile": false + if (redirect === false) { + return false; + } + + if (redirect) { + return path.join( + this.root, + /* $FlowFixMe: `getReplacements` doesn't validate the return value. */ + redirect + ); + } + + return name; + }); + } + + read() { + if (!this._reading) { + this._reading = new Promise( + resolve => resolve(JSON.parse(fs.readFileSync(this.path, 'utf8'))) + ); + } + + return this._reading; + } +} + +function getReplacements(pkg) { + let rn = pkg['react-native']; + let browser = pkg.browser; + if (rn == null) { + return browser; + } + + if (browser == null) { + return rn; + } + + if (typeof rn === 'string') { + /* $FlowFixMe: It is likely unsafe to assume all packages would + * contain a "main" */ + rn = { [pkg.main]: rn }; + } + + if (typeof browser === 'string') { + /* $FlowFixMe: It is likely unsafe to assume all packages would + * contain a "main" */ + browser = { [pkg.main]: browser }; + } + + // merge with "browser" as default, + // "react-native" as override + // $FlowFixMe(>=0.35.0) browser and rn should be objects + return { ...browser, ...rn }; +} + +module.exports = Package; diff --git a/packages/metro-bundler/react-packager/src/node-haste/Polyfill.js b/packages/metro-bundler/react-packager/src/node-haste/Polyfill.js new file mode 100644 index 00000000..8a282520 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/Polyfill.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2013-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. + * + * @flow + */ + +'use strict'; + +const Module = require('./Module'); + +import type {ConstructorArgs} from './Module'; + +class Polyfill extends Module { + + _id: string; + _dependencies: Array; + + constructor(options: ConstructorArgs & { + id: string, + dependencies: Array, + }) { + super(options); + this._id = options.id; + this._dependencies = options.dependencies; + } + + isHaste() { + return Promise.resolve(false); + } + + getName() { + return Promise.resolve(this._id); + } + + getPackage() { + return null; + } + + getDependencies() { + return Promise.resolve(this._dependencies); + } + + isJSON() { + return false; + } + + isPolyfill() { + return true; + } +} + +module.exports = Polyfill; diff --git a/packages/metro-bundler/react-packager/src/node-haste/__mocks__/fs.js b/packages/metro-bundler/react-packager/src/node-haste/__mocks__/fs.js new file mode 100644 index 00000000..2036e6ea --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/__mocks__/fs.js @@ -0,0 +1,11 @@ +/** + * 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'; + +module.exports = require('graceful-fs'); diff --git a/packages/metro-bundler/react-packager/src/node-haste/__mocks__/graceful-fs.js b/packages/metro-bundler/react-packager/src/node-haste/__mocks__/graceful-fs.js new file mode 100644 index 00000000..87597310 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/__mocks__/graceful-fs.js @@ -0,0 +1,307 @@ +/** + * 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'; + +const {dirname} = require.requireActual('path'); +const fs = jest.genMockFromModule('fs'); +const path = require('path'); +const stream = require.requireActual('stream'); + +const noop = () => {}; + +function asyncCallback(cb) { + return function() { + setImmediate(() => cb.apply(this, arguments)); + }; +} + +const mtime = { + getTime: () => Math.ceil(Math.random() * 10000000), +}; + +fs.realpath.mockImplementation((filepath, callback) => { + callback = asyncCallback(callback); + let node; + try { + node = getToNode(filepath); + } catch (e) { + return callback(e); + } + if (node && typeof node === 'object' && node.SYMLINK != null) { + return callback(null, node.SYMLINK); + } + callback(null, filepath); +}); + +fs.readdirSync.mockImplementation((filepath) => Object.keys(getToNode(filepath))); + +fs.readdir.mockImplementation((filepath, callback) => { + callback = asyncCallback(callback); + let node; + try { + node = getToNode(filepath); + if (node && typeof node === 'object' && node.SYMLINK != null) { + node = getToNode(node.SYMLINK); + } + } catch (e) { + return callback(e); + } + + if (!(node && typeof node === 'object' && node.SYMLINK == null)) { + return callback(new Error(filepath + ' is not a directory.')); + } + + callback(null, Object.keys(node)); +}); + +fs.readFile.mockImplementation(function(filepath, encoding, callback) { + callback = asyncCallback(callback); + if (arguments.length === 2) { + callback = encoding; + encoding = null; + } + + let node; + try { + node = getToNode(filepath); + // dir check + if (node && typeof node === 'object' && node.SYMLINK == null) { + callback(new Error('Error readFile a dir: ' + filepath)); + } + if (node == null) { + callback(Error('No such file: ' + filepath)); + } else { + callback(null, node); + } + } catch (e) { + return callback(e); + } +}); + +fs.readFileSync.mockImplementation(function(filepath, encoding) { + const node = getToNode(filepath); + // dir check + if (node && typeof node === 'object' && node.SYMLINK == null) { + throw new Error('Error readFileSync a dir: ' + filepath); + } + return node; +}); + +fs.stat.mockImplementation((filepath, callback) => { + callback = asyncCallback(callback); + let node; + try { + node = getToNode(filepath); + } catch (e) { + callback(e); + return; + } + + if (node.SYMLINK) { + fs.stat(node.SYMLINK, callback); + return; + } + + if (node && typeof node === 'object') { + callback(null, { + isDirectory: () => true, + isSymbolicLink: () => false, + mtime, + }); + } else { + callback(null, { + isDirectory: () => false, + isSymbolicLink: () => false, + mtime, + }); + } +}); + +fs.statSync.mockImplementation((filepath) => { + const node = getToNode(filepath); + + if (node.SYMLINK) { + return fs.statSync(node.SYMLINK); + } + + return { + isDirectory: () => node && typeof node === 'object', + isSymbolicLink: () => false, + mtime, + }; +}); + +fs.lstat.mockImplementation((filepath, callback) => { + callback = asyncCallback(callback); + let node; + try { + node = getToNode(filepath); + } catch (e) { + callback(e); + return; + } + + if (node && typeof node === 'object') { + callback(null, { + isDirectory: () => true, + isSymbolicLink: () => false, + mtime, + }); + } else { + callback(null, { + isDirectory: () => false, + isSymbolicLink: () => false, + mtime, + }); + } +}); + +fs.lstatSync.mockImplementation((filepath) => { + const node = getToNode(filepath); + + if (node.SYMLINK) { + return { + isDirectory: () => false, + isSymbolicLink: () => true, + mtime, + }; + } + + return { + isDirectory: () => node && typeof node === 'object', + isSymbolicLink: () => false, + mtime, + }; +}); + +fs.open.mockImplementation(function(filepath) { + const callback = arguments[arguments.length - 1] || noop; + let data, error, fd; + try { + data = getToNode(filepath); + } catch (e) { + error = e; + } + + if (error || data == null) { + error = Error(`ENOENT: no such file or directory, open ${filepath}`); + } + if (data != null) { + /* global Buffer: true */ + fd = {buffer: new Buffer(data, 'utf8'), position: 0}; + } + + callback(error, fd); +}); + +fs.read.mockImplementation((fd, buffer, writeOffset, length, position, callback = noop) => { + let bytesWritten; + try { + if (position == null || position < 0) { + ({position} = fd); + } + bytesWritten = fd.buffer.copy(buffer, writeOffset, position, position + length); + fd.position = position + bytesWritten; + } catch (e) { + callback(Error('invalid argument')); + return; + } + callback(null, bytesWritten, buffer); +}); + +fs.close.mockImplementation((fd, callback = noop) => { + try { + fd.buffer = fs.position = undefined; + } catch (e) { + callback(Error('invalid argument')); + return; + } + callback(null); +}); + +let filesystem; + +fs.createReadStream.mockImplementation(filepath => { + if (!filepath.startsWith('/')) { + throw Error('Cannot open file ' + filepath); + } + + const parts = filepath.split('/').slice(1); + let file = filesystem; + + for (const part of parts) { + file = file[part]; + if (!file) { + break; + } + } + + if (typeof file !== 'string') { + throw Error('Cannot open file ' + filepath); + } + + return new stream.Readable({ + read() { + this.push(file, 'utf8'); + this.push(null); + } + }); +}); + +fs.createWriteStream.mockImplementation(file => { + let node; + try { + node = getToNode(dirname(file)); + } finally { + if (typeof node === 'object') { + const writeStream = new stream.Writable({ + write(chunk) { + this.__chunks.push(chunk); + } + }); + writeStream.__file = file; + writeStream.__chunks = []; + writeStream.end = jest.fn(writeStream.end); + fs.createWriteStream.mock.returned.push(writeStream); + return writeStream; + } else { + throw new Error('Cannot open file ' + file); + } + } +}); +fs.createWriteStream.mock.returned = []; + + +fs.__setMockFilesystem = (object) => (filesystem = object); + +function getToNode(filepath) { + // Ignore the drive for Windows paths. + if (filepath.match(/^[a-zA-Z]:\\/)) { + filepath = filepath.substring(2); + } + + if (filepath.endsWith(path.sep)) { + filepath = filepath.slice(0, -1); + } + const parts = filepath.split(/[\/\\]/); + if (parts[0] !== '') { + throw new Error('Make sure all paths are absolute.'); + } + let node = filesystem; + parts.slice(1).forEach((part) => { + if (node && node.SYMLINK) { + node = getToNode(node.SYMLINK); + } + node = node[part]; + }); + + return node; +} + +module.exports = fs; diff --git a/packages/metro-bundler/react-packager/src/node-haste/__tests__/AssetModule-test.js b/packages/metro-bundler/react-packager/src/node-haste/__tests__/AssetModule-test.js new file mode 100644 index 00000000..ff52ad27 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/__tests__/AssetModule-test.js @@ -0,0 +1,28 @@ +/** + * 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 AssetModule = require('../AssetModule'); + +describe('AssetModule:', () => { + const defaults = {file: '/arbitrary'}; + + it('has no dependencies by default', () => { + return new AssetModule(defaults).getDependencies() + .then(deps => expect(deps).toEqual([])); + }); + + it('can be parametrized with dependencies', () => { + const dependencies = ['arbitrary', 'dependencies']; + return new AssetModule({...defaults, dependencies}).getDependencies() + .then(deps => expect(deps).toEqual(dependencies)); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/__tests__/DependencyGraph-test.js b/packages/metro-bundler/react-packager/src/node-haste/__tests__/DependencyGraph-test.js new file mode 100644 index 00000000..f1983031 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/__tests__/DependencyGraph-test.js @@ -0,0 +1,5510 @@ +/** + * 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.disableAutomock(); +jest.useRealTimers(); +jest + .mock('fs') + .mock('../../Logger') + .mock('../../lib/TransformCache') + // It's noticeably faster to prevent running watchman from FileWatcher. + .mock('child_process', () => ({})) + ; + +// This doesn't have state, and it's huge (Babel) so it's much faster to +// require it only once. +const extractDependencies = require('../../JSTransformer/worker/extract-dependencies'); +jest.mock('../../JSTransformer/worker/extract-dependencies', () => extractDependencies); + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; + +const path = require('path'); + +const mockStat = { + isDirectory: () => false, +}; + +beforeEach(() => { + jest.resetModules(); + + jest.mock('path', () => path); +}); + +describe('DependencyGraph', function() { + let Module; + let ResolutionRequest; + let defaults; + + function getOrderedDependenciesAsJSON(dgraph, entryPath, platform, recursive = true) { + return dgraph.getDependencies({entryPath, platform, recursive}) + .then(response => response.finalize()) + .then(({ dependencies }) => Promise.all(dependencies.map(dep => Promise.all([ + dep.getName(), + dep.getDependencies(), + ]).then(([name, moduleDependencies]) => ({ + path: dep.path, + isJSON: dep.isJSON(), + isAsset: dep.isAsset(), + isPolyfill: dep.isPolyfill(), + resolution: dep.resolution, + id: name, + dependencies: moduleDependencies, + }))) + )); + } + + beforeEach(function() { + jest.resetModules(); + + Module = require('../Module'); + ResolutionRequest = require('../DependencyGraph/ResolutionRequest'); + + const Cache = jest.genMockFn().mockImplementation(function() { + this._maps = Object.create(null); + }); + Cache.prototype.has = jest.genMockFn() + .mockImplementation(function(filepath, field) { + if (!(filepath in this._maps)) { + return false; + } + return !field || field in this._maps[filepath]; + }); + Cache.prototype.get = jest.genMockFn() + .mockImplementation(function(filepath, field, factory) { + let cacheForPath = this._maps[filepath]; + if (this.has(filepath, field)) { + return field ? cacheForPath[field] : cacheForPath; + } + + if (!cacheForPath) { + cacheForPath = this._maps[filepath] = Object.create(null); + } + const value = cacheForPath[field] = factory(); + return value; + }); + Cache.prototype.invalidate = jest.genMockFn() + .mockImplementation(function(filepath, field) { + if (!this.has(filepath, field)) { + return; + } + + if (field) { + delete this._maps[filepath][field]; + } else { + delete this._maps[filepath]; + } + }); + Cache.prototype.end = jest.genMockFn(); + + const transformCacheKey = 'abcdef'; + defaults = { + assetExts: ['png', 'jpg'], + cache: new Cache(), + forceNodeFilesystemAPI: true, + providesModuleNodeModules: [ + 'haste-fbjs', + 'react-haste', + 'react-native', + ], + platforms: ['ios', 'android'], + useWatchman: false, + maxWorkers: 1, + resetCache: true, + transformCode: (module, sourceCode, transformOptions) => { + return new Promise(resolve => { + let deps = {dependencies: [], dependencyOffsets: []}; + if (!module.path.endsWith('.json')) { + deps = extractDependencies(sourceCode); + } + resolve({...deps, code: sourceCode}); + }); + }, + transformCacheKey, + reporter: require('../../lib/reporting').nullReporter, + }; + }); + + describe('get sync dependencies (posix)', function() { + let DependencyGraph; + const consoleWarn = console.warn; + const realPlatform = process.platform; + beforeEach(function() { + process.platform = 'linux'; + DependencyGraph = require('../index'); + }); + + afterEach(function() { + console.warn = consoleWarn; + process.platform = realPlatform; + }); + + it('should get dependencies', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + 'require("b")', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule b', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'a', + path: '/root/a.js', + dependencies: ['b'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'b', + path: '/root/b.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should resolve relative entry path', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, 'index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get shallow dependencies', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + 'require("b")', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule b', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js', null, false).then((deps) => { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: '/root/a.js', + dependencies: ['b'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get dependencies with the correct extensions', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + ].join('\n'), + 'a.js.orig': [ + '/**', + ' * @providesModule a', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: '/root/a.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get json dependencies', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'package.json': JSON.stringify({ + name: 'package', + }), + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./a.json")', + 'require("./b")', + ].join('\n'), + 'a.json': JSON.stringify({}), + 'b.json': JSON.stringify({}), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['./a.json', './b'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'package/a.json', + isJSON: true, + path: '/root/a.json', + dependencies: [], + isAsset: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'package/b.json', + isJSON: true, + path: '/root/b.json', + dependencies: [], + isAsset: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get package json as a dep', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'package.json': JSON.stringify({ + name: 'package', + }), + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./package.json")', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['./package.json'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'package/package.json', + isJSON: true, + path: '/root/package.json', + dependencies: [], + isAsset: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get dependencies with relative assets', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./imgs/a.png")', + ].join('\n'), + 'imgs': { + 'a.png': '', + }, + 'package.json': JSON.stringify({ + name: 'rootPackage', + }), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['./imgs/a.png'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'rootPackage/imgs/a.png', + path: '/root/imgs/a.png', + dependencies: [], + isAsset: true, + resolution: 1, + isJSON: false, + isPolyfill: false, + }, + ]); + }); + }); + + it('should get dependencies with assets and resolution', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./imgs/a.png");', + 'require("./imgs/b.png");', + 'require("./imgs/c.png");', + ].join('\n'), + 'imgs': { + 'a@1.5x.png': '', + 'b@.7x.png': '', + 'c.png': '', + 'c@2x.png': '', + }, + 'package.json': JSON.stringify({ + name: 'rootPackage', + }), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: [ + './imgs/a.png', + './imgs/b.png', + './imgs/c.png', + ], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'rootPackage/imgs/a.png', + path: '/root/imgs/a@1.5x.png', + resolution: 1.5, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/b.png', + path: '/root/imgs/b@.7x.png', + resolution: 0.7, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/c.png', + path: '/root/imgs/c.png', + resolution: 1, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + ]); + }); + }); + + it('should respect platform extension in assets', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./imgs/a.png");', + 'require("./imgs/b.png");', + 'require("./imgs/c.png");', + ].join('\n'), + 'imgs': { + 'a@1.5x.ios.png': '', + 'b@.7x.ios.png': '', + 'c.ios.png': '', + 'c@2x.ios.png': '', + }, + 'package.json': JSON.stringify({ + name: 'rootPackage', + }), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js', 'ios').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: [ + './imgs/a.png', + './imgs/b.png', + './imgs/c.png', + ], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'rootPackage/imgs/a.png', + path: '/root/imgs/a@1.5x.ios.png', + resolution: 1.5, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/b.png', + path: '/root/imgs/b@.7x.ios.png', + resolution: 0.7, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/c.png', + path: '/root/imgs/c.ios.png', + resolution: 1, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + ]); + }); + }); + + it('should get recursive dependencies', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + 'require("index")', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: '/root/a.js', + dependencies: ['index'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with packages', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with packages', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage/")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage/'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with packages with a dot in the name', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("sha.js")', + 'require("x.y.z")', + ].join('\n'), + 'sha.js': { + 'package.json': JSON.stringify({ + name: 'sha.js', + main: 'main.js', + }), + 'main.js': 'lol', + }, + 'x.y.z': { + 'package.json': JSON.stringify({ + name: 'x.y.z', + main: 'main.js', + }), + 'main.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['sha.js', 'x.y.z'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'sha.js/main.js', + path: '/root/sha.js/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'x.y.z/main.js', + path: '/root/x.y.z/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should default main package to index.js', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': 'require("aPackage")', + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + }), + 'index.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should resolve using alternative ids', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': 'require("aPackage")', + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + }), + 'index.js': [ + '/**', + ' * @providesModule EpicModule', + ' */', + ].join('\n'), + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'EpicModule', + path: '/root/aPackage/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should default use index.js if main is a dir', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': 'require("aPackage")', + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'lib', + }), + lib: { + 'index.js': 'lol', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/lib/index.js', + path: '/root/aPackage/lib/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should resolve require to index if it is a dir', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'package.json': JSON.stringify({ + name: 'test', + }), + 'index.js': 'require("./lib/")', + lib: { + 'index.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'test/index.js', + path: '/root/index.js', + dependencies: ['./lib/'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'test/lib/index.js', + path: '/root/lib/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should resolve require to main if it is a dir w/ a package.json', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'package.json': JSON.stringify({ + name: 'test', + }), + 'index.js': 'require("./lib/")', + lib: { + 'package.json': JSON.stringify({ + 'main': 'main.js', + }), + 'index.js': 'lol', + 'main.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'test/index.js', + path: '/root/index.js', + dependencies: ['./lib/'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/lib/main.js', + path: '/root/lib/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should ignore malformed packages', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + ].join('\n'), + 'aPackage': { + 'package.json': 'lol', + 'main.js': 'lol', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should fatal on multiple modules with the same name', function() { + const root = '/root'; + console.warn = jest.fn(); + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule index', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + + return dgraph.load().catch(err => { + expect(err.message).toEqual( + `Failed to build DependencyGraph: @providesModule naming collision:\n` + + ` Duplicate module name: index\n` + + ` Paths: /root/b.js collides with /root/index.js\n\n` + + 'This error is caused by a @providesModule declaration ' + + 'with the same name across two different files.' + ); + expect(err.type).toEqual('DependencyGraphError'); + expect(console.warn).toBeCalled(); + }); + }); + + it('throws when a module is missing', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("lolomg")', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').catch( + error => { + expect(error.type).toEqual('UnableToResolveError'); + } + ); + }); + + it('should work with packages with subdirs', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage/subdir/lolynot")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'lol', + 'subdir': { + 'lolynot.js': 'lolynot', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage/subdir/lolynot'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/subdir/lolynot.js', + path: '/root/aPackage/subdir/lolynot.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should work with packages with symlinked subdirs', function() { + var root = '/root'; + setMockFileSystem({ + 'symlinkedPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'lol', + 'subdir': { + 'lolynot.js': 'lolynot', + }, + }, + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage/subdir/lolynot")', + ].join('\n'), + 'aPackage': { SYMLINK: '/symlinkedPackage' }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage/subdir/lolynot'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/subdir/lolynot.js', + path: '/root/aPackage/subdir/lolynot.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should work with relative modules in packages', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'require("./subdir/lolynot")', + 'subdir': { + 'lolynot.js': 'require("../other")', + }, + 'other.js': '/* some code */', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: ['./subdir/lolynot'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/subdir/lolynot.js', + path: '/root/aPackage/subdir/lolynot.js', + dependencies: ['../other'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/other.js', + path: '/root/aPackage/other.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + testBrowserField('browser'); + testBrowserField('react-native'); + + function replaceBrowserField(json, fieldName) { + if (fieldName !== 'browser') { + json[fieldName] = json.browser; + delete json.browser; + } + + return json; + } + + function testBrowserField(fieldName) { + it('should support simple browser field in packages ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + main: 'main.js', + browser: 'client.js', + }, fieldName)), + 'main.js': 'some other code', + 'client.js': '/* some code */', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/client.js', + path: '/root/aPackage/client.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should support browser field in packages w/o .js ext ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + main: 'main.js', + browser: 'client', + }, fieldName)), + 'main.js': 'some other code', + 'client.js': '/* some code */', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/client.js', + path: '/root/aPackage/client.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should support mapping main in browser field json ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + main: './main.js', + browser: { + './main.js': './client.js', + }, + }, fieldName)), + 'main.js': 'some other code', + 'client.js': '/* some code */', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + assetExts: ['png', 'jpg'], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/client.js', + path: '/root/aPackage/client.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should work do correct browser mapping w/o js ext ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + main: './main.js', + browser: { + './main': './client.js', + }, + }, fieldName)), + 'main.js': 'some other code', + 'client.js': '/* some code */', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + assetExts: ['png', 'jpg'], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/client.js', + path: '/root/aPackage/client.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should support browser mapping of files ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + main: './main.js', + browser: { + './main': './client.js', + './node.js': './not-node.js', + './not-browser': './browser.js', + './dir/server.js': './dir/client', + './hello.js': './bye.js', + }, + }, fieldName)), + 'main.js': '/* some other code */', + 'client.js': 'require("./node")\nrequire("./dir/server.js")', + 'not-node.js': 'require("./not-browser")', + 'not-browser.js': 'require("./dir/server")', + 'browser.js': '/* some browser code */', + 'dir': { + 'server.js': '/* some node code */', + 'client.js': 'require("../hello")', + }, + 'hello.js': '/* hello */', + 'bye.js': '/* bye */', + }, + }, + }); + + const dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/client.js', + path: '/root/aPackage/client.js', + dependencies: ['./node', './dir/server.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/not-node.js', + path: '/root/aPackage/not-node.js', + dependencies: ['./not-browser'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/browser.js', + path: '/root/aPackage/browser.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/dir/client.js', + path: '/root/aPackage/dir/client.js', + dependencies: ['../hello'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/bye.js', + path: '/root/aPackage/bye.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should support browser mapping for packages ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + browser: { + 'node-package': 'browser-package', + }, + }, fieldName)), + 'index.js': 'require("node-package")', + 'node-package': { + 'package.json': JSON.stringify({ + 'name': 'node-package', + }), + 'index.js': '/* some node code */', + }, + 'browser-package': { + 'package.json': JSON.stringify({ + 'name': 'browser-package', + }), + 'index.js': '/* some browser code */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['node-package'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'browser-package/index.js', + path: '/root/aPackage/browser-package/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should support browser mapping of a package to a file ("' + fieldName + '")', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + browser: { + 'node-package': './dir/browser.js', + }, + }, fieldName)), + 'index.js': 'require("./dir/ooga")', + 'dir': { + 'ooga.js': 'require("node-package")', + 'browser.js': '/* some browser code */', + }, + 'node-package': { + 'package.json': JSON.stringify({ + 'name': 'node-package', + }), + 'index.js': '/* some node code */', + }, + }, + }, + }); + + const dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['./dir/ooga'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/dir/ooga.js', + path: '/root/aPackage/dir/ooga.js', + dependencies: ['node-package'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/dir/browser.js', + path: '/root/aPackage/dir/browser.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should support browser mapping for packages ("' + fieldName + '")', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + browser: { + 'node-package': 'browser-package', + }, + }, fieldName)), + 'index.js': 'require("node-package")', + 'node-package': { + 'package.json': JSON.stringify({ + 'name': 'node-package', + }), + 'index.js': '/* some node code */', + }, + 'browser-package': { + 'package.json': JSON.stringify({ + 'name': 'browser-package', + }), + 'index.js': '/* some browser code */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['node-package'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'browser-package/index.js', + path: '/root/aPackage/browser-package/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should support browser exclude of a package ("' + fieldName + '")', function() { + ResolutionRequest.emptyModule = '/root/emptyModule.js'; + var root = '/root'; + setMockFileSystem({ + 'root': { + 'emptyModule.js': '', + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + browser: { + 'booga': false, + }, + }, fieldName)), + 'index.js': 'require("booga")', + 'booga': { + 'package.json': JSON.stringify({ + 'name': 'booga', + }), + 'index.js': '/* some node code */', + }, + }, + }, + }); + + const dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['booga'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + dependencies: [], + id: '/root/emptyModule.js', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/emptyModule.js', + resolution: undefined, + }, + ]); + }); + }); + + it('should support browser exclude of a file ("' + fieldName + '")', function() { + ResolutionRequest.emptyModule = '/root/emptyModule.js'; + + var root = '/root'; + setMockFileSystem({ + 'root': { + 'emptyModule.js': '', + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify(replaceBrowserField({ + name: 'aPackage', + browser: { + './booga.js': false, + }, + }, fieldName)), + 'index.js': 'require("./booga")', + 'booga.js': '/* some node code */', + }, + }, + }); + + const dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['./booga'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + dependencies: [], + id: '/root/emptyModule.js', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/emptyModule.js', + resolution: undefined, + }, + ]); + }); + }); + } + + it('should fall back to browser mapping from react-native mapping', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + 'react-native': { + 'node-package': 'rn-package', + }, + }), + 'index.js': 'require("node-package")', + 'node_modules': { + 'node-package': { + 'package.json': JSON.stringify({ + 'name': 'node-package', + }), + 'index.js': '/* some node code */', + }, + 'rn-package': { + 'package.json': JSON.stringify({ + 'name': 'rn-package', + browser: { + 'nested-package': 'nested-browser-package', + }, + }), + 'index.js': 'require("nested-package")', + }, + 'nested-browser-package': { + 'package.json': JSON.stringify({ + 'name': 'nested-browser-package', + }), + 'index.js': '/* some code */', + }, + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['node-package'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'rn-package/index.js', + path: '/root/aPackage/node_modules/rn-package/index.js', + dependencies: ['nested-package'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'nested-browser-package/index.js', + path: '/root/aPackage/node_modules/nested-browser-package/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with absolute paths', () => { + const root = '/root'; + setMockFileSystem({ + [root.slice(1)]: { + 'index.js': 'require("/root/apple.js");', + 'apple.js': '', + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['/root/apple.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/apple.js', + path: '/root/apple.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should merge browser mapping with react-native mapping', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + 'react-native': { + // should see this: + 'node-package-a': 'rn-package-a', + // should see this: + 'node-package-c': 'rn-package-d', + }, + 'browser': { + // should see this: + 'node-package-b': 'rn-package-b', + // should NOT see this: + 'node-package-c': 'rn-package-c', + }, + }), + 'index.js': + 'require("node-package-a"); require("node-package-b"); require("node-package-c");', + 'node_modules': { + 'node-package-a': { + 'package.json': JSON.stringify({ + 'name': 'node-package-a', + }), + 'index.js': '/* some node code */', + }, + 'node-package-b': { + 'package.json': JSON.stringify({ + 'name': 'node-package-b', + }), + 'index.js': '/* some node code */', + }, + 'node-package-c': { + 'package.json': JSON.stringify({ + 'name': 'node-package-c', + }), + 'index.js': '/* some node code */', + }, + 'node-package-d': { + 'package.json': JSON.stringify({ + 'name': 'node-package-d', + }), + 'index.js': '/* some node code */', + }, + 'rn-package-a': { + 'package.json': JSON.stringify({ + 'name': 'rn-package-a', + }), + 'index.js': '/* some rn code */', + }, + 'rn-package-b': { + 'package.json': JSON.stringify({ + 'name': 'rn-package-b', + }), + 'index.js': '/* some rn code */', + }, + 'rn-package-c': { + 'package.json': JSON.stringify({ + 'name': 'rn-package-c', + }), + 'index.js': '/* some rn code */', + }, + 'rn-package-d': { + 'package.json': JSON.stringify({ + 'name': 'rn-package-d', + }), + 'index.js': '/* some rn code */', + }, + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'aPackage/index.js', + path: '/root/aPackage/index.js', + dependencies: ['node-package-a', 'node-package-b', 'node-package-c'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'rn-package-a/index.js', + path: '/root/aPackage/node_modules/rn-package-a/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'rn-package-b/index.js', + path: '/root/aPackage/node_modules/rn-package-b/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { id: 'rn-package-d/index.js', + path: '/root/aPackage/node_modules/rn-package-d/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should fall back to `extraNodeModules`', () => { + const root = '/root'; + setMockFileSystem({ + [root.slice(1)]: { + 'index.js': 'require("./foo")', + 'foo': { + 'index.js': 'require("bar")', + }, + 'provides-bar': { + 'package.json': '{"main": "lib/bar.js"}', + 'lib': { + 'bar.js': '', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + extraNodeModules: { + 'bar': root + '/provides-bar', + }, + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['./foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/foo/index.js', + path: '/root/foo/index.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/provides-bar/lib/bar.js', + path: '/root/provides-bar/lib/bar.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it( + 'should only use `extraNodeModules` after checking all possible filesystem locations', + () => { + const root = '/root'; + setMockFileSystem({ + [root.slice(1)]: { + 'index.js': 'require("bar")', + 'node_modules': { 'bar.js': '' }, + 'provides-bar': { 'index.js': '' }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + extraNodeModules: { + 'bar': root + '/provides-bar', + }, + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/node_modules/bar.js', + path: '/root/node_modules/bar.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + } + ); + + it('should be able to resolve paths within `extraNodeModules`', () => { + const root = '/root'; + setMockFileSystem({ + [root.slice(1)]: { + 'index.js': 'require("bar/lib/foo")', + 'provides-bar': { + 'package.json': '{}', + 'lib': {'foo.js': ''}, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + extraNodeModules: { + 'bar': root + '/provides-bar', + }, + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['bar/lib/foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/provides-bar/lib/foo.js', + path: '/root/provides-bar/lib/foo.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + }); + + describe('get sync dependencies (win32)', function() { + const realPlatform = process.platform; + let DependencyGraph; + beforeEach(function() { + process.platform = 'win32'; + + // reload path module + jest.resetModules(); + jest.mock('path', () => path.win32); + DependencyGraph = require('../index'); + }); + + afterEach(function() { + process.platform = realPlatform; + }); + + it('should get dependencies', function() { + const root = 'C:\\root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + 'require("b")', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule b', + ' */', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, 'C:\\root\\index.js').then((deps) => { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'a', + path: 'C:\\root\\a.js', + dependencies: ['b'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'b', + path: 'C:\\root\\b.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + + it('should work with absolute paths', () => { + const root = 'C:\\root'; + setMockFileSystem({ + 'root': { + 'index.js': 'require("C:\\\\root\\\\apple.js");', + 'apple.js': '', + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, 'C:\\root\\index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'C:\\root\\index.js', + path: 'C:\\root\\index.js', + dependencies: ['C:\\root\\apple.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'C:\\root\\apple.js', + path: 'C:\\root\\apple.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should get dependencies with assets and resolution', function() { + const root = 'C:\\root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./imgs/a.png");', + 'require("./imgs/b.png");', + 'require("./imgs/c.png");', + ].join('\n'), + 'imgs': { + 'a@1.5x.png': '', + 'b@.7x.png': '', + 'c.png': '', + 'c@2x.png': '', + }, + 'package.json': JSON.stringify({ + name: 'rootPackage', + }), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, 'C:\\root\\index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: [ + './imgs/a.png', + './imgs/b.png', + './imgs/c.png', + ], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'rootPackage/imgs/a.png', + path: 'C:\\root\\imgs\\a@1.5x.png', + resolution: 1.5, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/b.png', + path: 'C:\\root\\imgs\\b@.7x.png', + resolution: 0.7, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + { + id: 'rootPackage/imgs/c.png', + path: 'C:\\root\\imgs\\c.png', + resolution: 1, + dependencies: [], + isAsset: true, + isJSON: false, + isPolyfill: false, + }, + ]); + }); + }); + }); + + describe('node_modules (posix)', function() { + const realPlatform = process.platform; + let DependencyGraph; + beforeEach(function() { + process.platform = 'linux'; + DependencyGraph = require('../index'); + }); + + afterEach(function() { + process.platform = realPlatform; + }); + + it('should work with nested node_modules', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 1 module */', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: '/root/node_modules/foo/node_modules/bar/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: '/root/node_modules/bar/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('platform should work with node_modules', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + }), + 'index.ios.js': '', + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main', + }), + 'main.ios.js': '', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.ios.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/index.ios.js', + path: '/root/node_modules/foo/index.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.ios.js', + path: '/root/node_modules/bar/main.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('nested node_modules with specific paths', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar/");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar/lol");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 1 module */', + 'lol.js': '', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo', 'bar/'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: ['bar/lol'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/lol.js', + path: '/root/node_modules/foo/node_modules/bar/lol.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: '/root/node_modules/bar/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('nested node_modules with browser field', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar/lol");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + browser: { + './lol': './wow', + }, + }), + 'main.js': '/* bar 1 module */', + 'lol.js': '', + 'wow.js': '', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + browser: './main2', + }), + 'main2.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: ['bar/lol'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/lol.js', + path: '/root/node_modules/foo/node_modules/bar/lol.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main2.js', + path: '/root/node_modules/bar/main2.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('node_modules should support multi level', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': '', + }, + }, + 'path': { + 'to': { + 'bar.js': [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")', + ].join('\n'), + }, + 'node_modules': {}, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar', + path: '/root/path/to/bar.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should selectively ignore providesModule in node_modules', function() { + var root = '/root'; + var otherRoot = '/anotherRoot'; + const filesystem = { + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("shouldWork");', + 'require("dontWork");', + 'require("wontWork");', + 'require("ember");', + 'require("internalVendoredPackage");', + 'require("anotherIndex");', + ].join('\n'), + 'node_modules': { + 'react-haste': { + 'package.json': JSON.stringify({ + name: 'react-haste', + main: 'main.js', + }), + // @providesModule should not be ignored here, because react-haste is whitelisted + 'main.js': [ + '/**', + ' * @providesModule shouldWork', + ' */', + 'require("submodule");', + ].join('\n'), + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + // @providesModule should be ignored here, because it's not whitelisted + 'main.js':[ + '/**', + ' * @providesModule dontWork', + ' */', + 'hi();', + ].join('\n'), + }, + 'submodule': { + 'package.json': JSON.stringify({ + name: 'submodule', + main: 'main.js', + }), + 'main.js': 'log()', + }, + }, + }, + 'ember': { + 'package.json': JSON.stringify({ + name: 'ember', + main: 'main.js', + }), + // @providesModule should be ignored here, because it's not whitelisted, + // and also, the modules "id" should be ember/main.js, not it's haste name + 'main.js':[ + '/**', + ' * @providesModule wontWork', + ' */', + 'hi();', + ].join('\n'), + }, + }, + // This part of the dep graph is meant to emulate internal facebook infra. + // By whitelisting `vendored_modules`, haste should still work. + 'vendored_modules': { + 'a-vendored-package': { + 'package.json': JSON.stringify({ + name: 'a-vendored-package', + main: 'main.js', + }), + // @providesModule should _not_ be ignored here, because it's whitelisted. + 'main.js':[ + '/**', + ' * @providesModule internalVendoredPackage', + ' */', + 'hiFromInternalPackage();', + ].join('\n'), + }, + }, + }, + // we need to support multiple roots and using haste between them + 'anotherRoot': { + 'index.js': [ + '/**', + ' * @providesModule anotherIndex', + ' */', + 'wazup()', + ].join('\n'), + }, + }; + setMockFileSystem(filesystem); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root, otherRoot], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').catch( + error => { + expect(error.type).toEqual('UnableToResolveError'); + } + ).then(() => { + filesystem.root['index.js'] = filesystem.root['index.js'] + .replace('require("dontWork")', '') + .replace('require("wontWork")', ''); + dgraph.processFileChange('change', root + '/index.js', mockStat); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js') + .then(deps => { + expect(deps).toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: [ + 'shouldWork', + 'ember', + 'internalVendoredPackage', + 'anotherIndex', + ], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'shouldWork', + path: '/root/node_modules/react-haste/main.js', + dependencies: ['submodule'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'submodule/main.js', + path: '/root/node_modules/react-haste/node_modules/submodule/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'ember/main.js', + path: '/root/node_modules/ember/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'internalVendoredPackage', + path: '/root/vendored_modules/a-vendored-package/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'anotherIndex', + path: '/anotherRoot/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + }); + + it('should not be confused by prev occuring whitelisted names', function() { + var root = '/react-haste'; + setMockFileSystem({ + 'react-haste': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("shouldWork");', + ].join('\n'), + 'node_modules': { + 'react-haste': { + 'package.json': JSON.stringify({ + name: 'react-haste', + main: 'main.js', + }), + 'main.js': [ + '/**', + ' * @providesModule shouldWork', + ' */', + ].join('\n'), + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/react-haste/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/react-haste/index.js', + dependencies: ['shouldWork'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'shouldWork', + path: '/react-haste/node_modules/react-haste/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with node packages with a .js in the name', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("sha.js")', + ].join('\n'), + 'node_modules': { + 'sha.js': { + 'package.json': JSON.stringify({ + name: 'sha.js', + main: 'main.js', + }), + 'main.js': 'lol', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['sha.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'sha.js/main.js', + path: '/root/node_modules/sha.js/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with multiple platforms (haste)', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('a'); + `, + 'a.ios.js': ` + /** + * @providesModule a + */ + `, + 'a.android.js': ` + /** + * @providesModule a + */ + `, + 'a.js': ` + /** + * @providesModule a + */ + `, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.ios.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: '/root/a.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should pick the generic file', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('a'); + `, + 'a.android.js': ` + /** + * @providesModule a + */ + `, + 'a.js': ` + /** + * @providesModule a + */ + `, + 'a.web.js': ` + /** + * @providesModule a + */ + `, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + platforms: ['ios', 'android', 'web'], + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.ios.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: '/root/a.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with multiple platforms (node)', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('./a'); + `, + 'a.ios.js': '', + 'a.android.js': '', + 'a.js': '', + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.ios.js', + dependencies: ['./a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: '/root/a.ios.js', + path: '/root/a.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should require package.json', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo/package.json");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': 'require("./package.json")', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo/package.json', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/package.json', + path: '/root/node_modules/foo/package.json', + dependencies: [], + isAsset: false, + isJSON: true, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: '/root/node_modules/bar/main.js', + dependencies: ['./package.json'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/package.json', + path: '/root/node_modules/bar/package.json', + dependencies: [], + isAsset: false, + isJSON: true, + isPolyfill: false, + resolution: undefined, + }, + + ]); + }); + }); + + it('should work with one-character node_modules', () => { + const root = '/root'; + setMockFileSystem({ + [root.slice(1)]: { + 'index.js': 'require("a/index.js");', + 'node_modules': { + 'a': { + 'package.json': '{"name": "a", "version": "1.2.3"}', + 'index.js': '', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: '/root/index.js', + path: '/root/index.js', + dependencies: ['a/index.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a/index.js', + path: '/root/node_modules/a/index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + }); + + describe('node_modules (win32)', function() { + const realPlatform = process.platform; + + // these tests will not work in a simulated way on linux testing VMs + // due to the drive letter expectation + if (realPlatform !== 'win32') { return; } + + const DependencyGraph = require('../index'); + + it('should work with nested node_modules', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 1 module */', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: 'C:\\root\\node_modules\\foo\\main.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: 'C:\\root\\node_modules\\foo\\node_modules\\bar\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: 'C:\\root\\node_modules\\bar\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('platform should work with node_modules', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + }), + 'index.ios.js': '', + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main', + }), + 'main.ios.js': '', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.ios.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/index.ios.js', + path: 'C:\\root\\node_modules\\foo\\index.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.ios.js', + path: 'C:\\root\\node_modules\\bar\\main.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('nested node_modules with specific paths', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar/");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar/lol");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 1 module */', + 'lol.js': '', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['foo', 'bar/'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: 'C:\\root\\node_modules\\foo\\main.js', + dependencies: ['bar/lol'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/lol.js', + path: 'C:\\root\\node_modules\\foo\\node_modules\\bar\\lol.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: 'C:\\root\\node_modules\\bar\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('nested node_modules with browser field', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar/lol");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + browser: { + './lol': './wow', + }, + }), + 'main.js': '/* bar 1 module */', + 'lol.js': '', + 'wow.js': '', + }, + }, + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + browser: './main2', + }), + 'main2.js': '/* bar 2 module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['foo', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: 'C:\\root\\node_modules\\foo\\main.js', + dependencies: ['bar/lol'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/lol.js', + path: 'C:\\root\\node_modules\\foo\\node_modules\\bar\\lol.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main2.js', + path: 'C:\\root\\node_modules\\bar\\main2.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('node_modules should support multi level', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': '', + }, + }, + 'path': { + 'to': { + 'bar.js': [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")', + ].join('\n'), + }, + 'node_modules': {}, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar', + path: 'C:\\root\\path\\to\\bar.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/main.js', + path: 'C:\\root\\node_modules\\foo\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should selectively ignore providesModule in node_modules', function() { + var root = '/root'; + var otherRoot = '/anotherRoot'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("shouldWork");', + 'require("dontWork");', + 'require("wontWork");', + 'require("ember");', + 'require("internalVendoredPackage");', + 'require("anotherIndex");', + ].join('\n'), + 'node_modules': { + 'react-haste': { + 'package.json': JSON.stringify({ + name: 'react-haste', + main: 'main.js', + }), + // @providesModule should not be ignored here, because react-haste is whitelisted + 'main.js': [ + '/**', + ' * @providesModule shouldWork', + ' */', + 'require("submodule");', + ].join('\n'), + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + // @providesModule should be ignored here, because it's not whitelisted + 'main.js':[ + '/**', + ' * @providesModule dontWork', + ' */', + 'hi();', + ].join('\n'), + }, + 'submodule': { + 'package.json': JSON.stringify({ + name: 'submodule', + main: 'main.js', + }), + 'main.js': 'log()', + }, + }, + }, + 'ember': { + 'package.json': JSON.stringify({ + name: 'ember', + main: 'main.js', + }), + // @providesModule should be ignored here, because it's not whitelisted, + // and also, the modules "id" should be ember/main.js, not it's haste name + 'main.js':[ + '/**', + ' * @providesModule wontWork', + ' */', + 'hi();', + ].join('\n'), + }, + }, + // This part of the dep graph is meant to emulate internal facebook infra. + // By whitelisting `vendored_modules`, haste should still work. + 'vendored_modules': { + 'a-vendored-package': { + 'package.json': JSON.stringify({ + name: 'a-vendored-package', + main: 'main.js', + }), + // @providesModule should _not_ be ignored here, because it's whitelisted. + 'main.js':[ + '/**', + ' * @providesModule internalVendoredPackage', + ' */', + 'hiFromInternalPackage();', + ].join('\n'), + }, + }, + }, + // we need to support multiple roots and using haste between them + 'anotherRoot': { + 'index.js': [ + '/**', + ' * @providesModule anotherIndex', + ' */', + 'wazup()', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root, otherRoot], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: [ + 'shouldWork', + 'dontWork', + 'wontWork', + 'ember', + 'internalVendoredPackage', + 'anotherIndex', + ], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'shouldWork', + path: 'C:\\root\\node_modules\\react-haste\\main.js', + dependencies: ['submodule'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'submodule/main.js', + path: 'C:\\root\\node_modules\\react-haste\\node_modules\\submodule\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'ember/main.js', + path: 'C:\\root\\node_modules\\ember\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'internalVendoredPackage', + path: 'C:\\root\\vendored_modules\\a-vendored-package\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'anotherIndex', + path: 'C:\\anotherRoot\\index.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should not be confused by prev occuring whitelisted names', function() { + var root = '/react-haste'; + setMockFileSystem({ + 'react-haste': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("shouldWork");', + ].join('\n'), + 'node_modules': { + 'react-haste': { + 'package.json': JSON.stringify({ + name: 'react-haste', + main: 'main.js', + }), + 'main.js': [ + '/**', + ' * @providesModule shouldWork', + ' */', + ].join('\n'), + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/react-haste/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\react-haste\\index.js', + dependencies: ['shouldWork'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'shouldWork', + path: 'C:\\react-haste\\node_modules\\react-haste\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should ignore modules it cant find (assumes own require system)', function() { + // For example SourceMap.js implements it's own require system. + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo/lol");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': '/* foo module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['foo/lol'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with node packages with a .js in the name', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("sha.js")', + ].join('\n'), + 'node_modules': { + 'sha.js': { + 'package.json': JSON.stringify({ + name: 'sha.js', + main: 'main.js', + }), + 'main.js': 'lol', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['sha.js'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'sha.js/main.js', + path: 'C:\\root\\node_modules\\sha.js\\main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with multiple platforms (haste)', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('a'); + `, + 'a.ios.js': ` + /** + * @providesModule a + */ + `, + 'a.android.js': ` + /** + * @providesModule a + */ + `, + 'a.js': ` + /** + * @providesModule a + */ + `, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.ios.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: 'C:\\root\\a.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should pick the generic file', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('a'); + `, + 'a.android.js': ` + /** + * @providesModule a + */ + `, + 'a.js': ` + /** + * @providesModule a + */ + `, + 'a.web.js': ` + /** + * @providesModule a + */ + `, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.ios.js', + dependencies: ['a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'a', + path: 'C:\\root\\a.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should work with multiple platforms (node)', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.ios.js': ` + /** + * @providesModule index + */ + require('./a'); + `, + 'a.ios.js': '', + 'a.android.js': '', + 'a.js': '', + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.ios.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.ios.js', + dependencies: ['./a'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'C:\\root\\a.ios.js', + path: 'C:\\root\\a.ios.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + + it('should require package.json', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo/package.json");', + 'require("bar");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + }, + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': 'require("./package.json")', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(deps => { + expect(deps) + .toEqual([ + { + id: 'index', + path: 'C:\\root\\index.js', + dependencies: ['foo/package.json', 'bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'foo/package.json', + path: 'C:\\root\\node_modules\\foo\\package.json', + dependencies: [], + isAsset: false, + isJSON: true, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/main.js', + path: 'C:\\root\\node_modules\\bar\\main.js', + dependencies: ['./package.json'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar/package.json', + path: 'C:\\root\\node_modules\\bar\\package.json', + dependencies: [], + isAsset: false, + isJSON: true, + isPolyfill: false, + resolution: undefined, + }, + + ]); + }); + }); + }); + + describe('file watch updating', function() { + const realPlatform = process.platform; + let DependencyGraph; + + beforeEach(function() { + process.platform = 'linux'; + DependencyGraph = require('../index'); + }); + + afterEach(function() { + process.platform = realPlatform; + }); + + it('updates module dependencies', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root['index.js'] = + filesystem.root['index.js'].replace('require("foo")', ''); + dgraph.processFileChange('change', root + '/index.js', mockStat); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + }); + + it('updates module dependencies on file change', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root['index.js'] = + filesystem.root['index.js'].replace('require("foo")', ''); + dgraph.processFileChange('change', root + '/index.js', mockStat); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + ]); + }); + }); + }); + + it('updates module dependencies on file delete', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + delete filesystem.root.foo; + dgraph.processFileChange('delete', root + '/foo.js'); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js') + .catch(error => { + expect(error.type).toEqual('UnableToResolveError'); + }); + }); + }); + + it('updates module dependencies on file add', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root['bar.js'] = [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")', + ].join('\n'); + dgraph.processFileChange('add', root + '/bar.js', mockStat); + + filesystem.root.aPackage['main.js'] = 'require("bar")'; + dgraph.processFileChange('change', root + '/aPackage/main.js', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + }, + { + id: 'bar', + path: '/root/bar.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('updates module dependencies on relative asset add', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("./foo.png")', + ].join('\n'), + 'package.json': JSON.stringify({ + name: 'aPackage', + }), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + assetExts: ['png'], + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').catch( + error => { + expect(error.type).toEqual('UnableToResolveError'); + } + ).then(() => { + filesystem.root['foo.png'] = ''; + dgraph._hasteFS._files[root + '/foo.png'] = ['', 8648460, 1, []]; + dgraph.processFileChange('add', root + '/foo.png', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { + expect(deps2) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['./foo.png'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/foo.png', + path: '/root/foo.png', + dependencies: [], + isAsset: true, + resolution: 1, + isJSON: false, + isPolyfill: false, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('changes to browser field', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + 'browser.js': 'browser', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root.aPackage['package.json'] = JSON.stringify({ + name: 'aPackage', + main: 'main.js', + browser: 'browser.js', + }); + dgraph.processFileChange('change', root + '/aPackage/package.json', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/browser.js', + path: '/root/aPackage/browser.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('removes old package from cache', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + 'browser.js': 'browser', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root['index.js'] = [ + '/**', + ' * @providesModule index', + ' */', + 'require("bPackage")', + ].join('\n'); + filesystem.root.aPackage['package.json'] = JSON.stringify({ + name: 'bPackage', + main: 'main.js', + }); + dgraph.processFileChange('change', root + '/index.js', mockStat); + dgraph.processFileChange('change', root + '/aPackage/package.json', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + dependencies: ['bPackage'], + id: 'index', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/index.js', + resolution: undefined, + resolveDependency: undefined, + }, + { + dependencies: [], + id: 'aPackage/main.js', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/aPackage/main.js', + resolution: undefined, + }, + ]); + }); + }); + }); + + it('should update node package changes', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': 'require("bar");\n/* foo module */', + 'node_modules': { + 'bar': { + 'package.json': JSON.stringify({ + name: 'bar', + main: 'main.js', + }), + 'main.js': '/* bar 1 module */', + }, + }, + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: ['bar'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'bar/main.js', + path: '/root/node_modules/foo/node_modules/bar/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + + filesystem.root.node_modules.foo['main.js'] = 'lol'; + dgraph.processFileChange('change', root + '/node_modules/foo/main.js', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { + expect(deps2) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo/main.js', + path: '/root/node_modules/foo/main.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('should update node package main changes', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("foo");', + ].join('\n'), + 'node_modules': { + 'foo': { + 'package.json': JSON.stringify({ + name: 'foo', + main: 'main.js', + }), + 'main.js': '/* foo module */', + 'browser.js': '/* foo module */', + }, + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + filesystem.root.node_modules.foo['package.json'] = JSON.stringify({ + name: 'foo', + main: 'main.js', + browser: 'browser.js', + }); + dgraph.processFileChange('change', root + '/node_modules/foo/package.json', mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { + expect(deps2) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['foo'], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo/browser.js', + path: '/root/node_modules/foo/browser.js', + dependencies: [], + isAsset: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('should not error when the watcher reports a known file as added', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'var b = require("b");', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule b', + ' */', + 'module.exports = function() {};', + ].join('\n'), + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + dgraph.processFileChange('add', root + '/index.js', mockStat); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js'); + }); + }); + }); + + describe('Extensions', () => { + const realPlatform = process.platform; + let DependencyGraph; + beforeEach(function() { + process.platform = 'linux'; + DependencyGraph = require('../index'); + }); + + afterEach(function() { + process.platform = realPlatform; + }); + + it('supports custom file extensions', () => { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.jsx': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.coffee': [ + '/**', + ' * @providesModule a', + ' */', + ].join('\n'), + 'X.js': '', + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + extensions: ['jsx', 'coffee'], + }); + + return dgraph.matchFilesByPattern('.*') + .then(files => { + expect(files).toEqual([ + '/root/index.jsx', '/root/a.coffee', + ]); + }) + .then(() => getOrderedDependenciesAsJSON(dgraph, '/root/index.jsx')) + .then(deps => { + expect(deps).toEqual([ + { + dependencies: ['a'], + id: 'index', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/index.jsx', + resolution: undefined, + }, + { + dependencies: [], + id: 'a', + isAsset: false, + isJSON: false, + isPolyfill: false, + path: '/root/a.coffee', + resolution: undefined, + }, + ]); + }); + }); + }); + + describe('Progress updates', () => { + let dependencyGraph, onProgress; + + function makeModule(id, dependencies = []) { + return ` + /** + * @providesModule ${id} + */\n` + + dependencies.map(d => `require(${JSON.stringify(d)});`).join('\n'); + } + + function getDependencies() { + return dependencyGraph.getDependencies({ + entryPath: '/root/index.js', + onProgress, + }); + } + + beforeEach(function() { + onProgress = jest.genMockFn(); + setMockFileSystem({ + 'root': { + 'index.js': makeModule('index', ['a', 'b']), + 'a.js': makeModule('a', ['c', 'd']), + 'b.js': makeModule('b', ['d', 'e']), + 'c.js': makeModule('c'), + 'd.js': makeModule('d', ['f']), + 'e.js': makeModule('e', ['f']), + 'f.js': makeModule('f', ['g']), + 'g.js': makeModule('g'), + }, + }); + const DependencyGraph = require('../'); + dependencyGraph = new DependencyGraph({ + ...defaults, + roots: ['/root'], + }); + }); + + it('calls back for each finished module', () => { + return getDependencies().then(() => + expect(onProgress.mock.calls.length).toBe(8) + ); + }); + + it('increases the number of finished modules in steps of one', () => { + return getDependencies().then(() => { + const increments = onProgress.mock.calls.map(([finished]) => finished); + expect(increments).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + }); + + it('adds the number of discovered modules to the number of total modules', () => { + return getDependencies().then(() => { + const increments = onProgress.mock.calls.map(([, total]) => total); + expect(increments).toEqual([3, 5, 6, 6, 7, 7, 8, 8]); + }); + }); + }); + + describe('Asset module dependencies', () => { + let DependencyGraph; + beforeEach(() => { + DependencyGraph = require('../index'); + }); + + it('allows setting dependencies for asset modules', () => { + const assetDependencies = ['/root/apple.png', '/root/banana.png']; + + setMockFileSystem({ + 'root': { + 'index.js': 'require("./a.png")', + 'a.png' : '', + 'apple.png': '', + 'banana.png': '', + }, + }); + + const dependencyGraph = new DependencyGraph({ + ...defaults, + assetDependencies, + roots: ['/root'], + }); + + return dependencyGraph.getDependencies({ + entryPath: '/root/index.js', + }).then(({dependencies}) => { + const [, assetModule] = dependencies; + return assetModule.getDependencies() + .then(deps => expect(deps).toBe(assetDependencies)); + }); + }); + }); + + describe('Deterministic order of dependencies', () => { + let callDeferreds, dependencyGraph, moduleReadDeferreds; + let moduleRead; + let DependencyGraph; + + beforeEach(() => { + moduleRead = Module.prototype.read; + DependencyGraph = require('../index'); + setMockFileSystem({ + 'root': { + 'index.js': ` + require('./a'); + require('./b'); + `, + 'a.js': ` + require('./c'); + require('./d'); + `, + 'b.js': ` + require('./c'); + require('./d'); + `, + 'c.js': 'require("./e");', + 'd.js': '', + 'e.js': 'require("./f");', + 'f.js': 'require("./c");', // circular dependency + }, + }); + dependencyGraph = new DependencyGraph({ + ...defaults, + roots: ['/root'], + }); + moduleReadDeferreds = {}; + callDeferreds = [defer(), defer()]; // [a.js, b.js] + + Module.prototype.read = jest.genMockFn().mockImplementation(function() { + const returnValue = moduleRead.apply(this, arguments); + if (/\/[ab]\.js$/.test(this.path)) { + let deferred = moduleReadDeferreds[this.path]; + if (!deferred) { + deferred = moduleReadDeferreds[this.path] = defer(returnValue); + const index = Number(this.path.endsWith('b.js')); // 0 or 1 + callDeferreds[index].resolve(); + } + return deferred.promise; + } + + return returnValue; + }); + }); + + afterEach(() => { + Module.prototype.read = moduleRead; + }); + + it('produces a deterministic tree if the "a" module resolves first', () => { + const dependenciesPromise = getOrderedDependenciesAsJSON(dependencyGraph, 'index.js'); + + return Promise.all(callDeferreds.map(deferred => deferred.promise)) + .then(() => { + const main = moduleReadDeferreds['/root/a.js']; + main.promise.then(() => { + moduleReadDeferreds['/root/b.js'].resolve(); + }); + main.resolve(); + return dependenciesPromise; + }).then(result => { + const names = result.map(({path: resultPath}) => resultPath.split('/').pop()); + expect(names).toEqual([ + 'index.js', + 'a.js', + 'c.js', + 'e.js', + 'f.js', + 'd.js', + 'b.js', + ]); + }); + }); + + it('produces a deterministic tree if the "b" module resolves first', () => { + const dependenciesPromise = getOrderedDependenciesAsJSON(dependencyGraph, 'index.js'); + + return Promise.all(callDeferreds.map(deferred => deferred.promise)) + .then(() => { + const main = moduleReadDeferreds['/root/b.js']; + main.promise.then(() => { + moduleReadDeferreds['/root/a.js'].resolve(); + }); + main.resolve(); + return dependenciesPromise; + }).then(result => { + const names = result.map(({path: resultPath}) => resultPath.split('/').pop()); + expect(names).toEqual([ + 'index.js', + 'a.js', + 'c.js', + 'e.js', + 'f.js', + 'd.js', + 'b.js', + ]); + }); + }); + }); + + function defer(value) { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve: () => resolve(value)}; + } + + function setMockFileSystem(object) { + return require('graceful-fs').__setMockFilesystem(object); + } +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/__tests__/Module-test.js b/packages/metro-bundler/react-packager/src/node-haste/__tests__/Module-test.js new file mode 100644 index 00000000..37af632f --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/__tests__/Module-test.js @@ -0,0 +1,440 @@ +/** + * 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 + .dontMock('absolute-path') + .dontMock('json-stable-stringify') + .dontMock('imurmurhash') + .dontMock('../lib/replacePatterns') + .dontMock('../DependencyGraph/docblock') + .dontMock('../Module'); + +jest + .mock('fs'); + +const Module = require('../Module'); +const ModuleCache = require('../ModuleCache'); +const DependencyGraphHelpers = require('../DependencyGraph/DependencyGraphHelpers'); +const TransformCache = require('../../lib/TransformCache'); +const fs = require('graceful-fs'); + +const packageJson = + JSON.stringify({ + name: 'arbitrary', + version: '1.0.0', + description: "A require('foo') story", + }); + +function mockFS(rootChildren) { + fs.__setMockFilesystem({root: rootChildren}); +} + +function mockPackageFile() { + mockFS({'package.json': packageJson}); +} + +function mockIndexFile(indexJs) { + mockFS({'index.js': indexJs}); +} + +describe('Module', () => { + const fileName = '/root/index.js'; + + let cache; + + const createCache = () => ({ + get: jest.genMockFn().mockImplementation( + (filepath, field, cb) => cb(filepath) + ), + invalidate: jest.genMockFn(), + end: jest.genMockFn(), + }); + + let transformCacheKey; + const createModule = (options) => + new Module({ + options: { + cacheTransformResults: true, + }, + transformCode: (module, sourceCode, transformOptions) => { + return Promise.resolve({code: sourceCode}); + }, + ...options, + cache, + file: options && options.file || fileName, + depGraphHelpers: new DependencyGraphHelpers(), + moduleCache: new ModuleCache({cache}), + transformCacheKey, + }); + + const createJSONModule = + (options) => createModule({...options, file: '/root/package.json'}); + + beforeEach(function() { + process.platform = 'linux'; + cache = createCache(); + transformCacheKey = 'abcdef'; + TransformCache.mock.reset(); + }); + + describe('Module ID', () => { + const moduleId = 'arbitraryModule'; + const source = + `/** + * @providesModule ${moduleId} + */ + `; + + let module; + beforeEach(() => { + module = createModule(); + }); + + describe('@providesModule annotations', () => { + beforeEach(() => { + mockIndexFile(source); + }); + + it('extracts the module name from the header', () => + module.getName().then(name => expect(name).toEqual(moduleId)) + ); + + it('identifies the module as haste module', () => + module.isHaste().then(isHaste => expect(isHaste).toBe(true)) + ); + + it('does not transform the file in order to access the name', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).getName() + .then(() => expect(transformCode).not.toBeCalled()); + }); + + it('does not transform the file in order to access the haste status', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).isHaste() + .then(() => expect(transformCode).not.toBeCalled()); + }); + }); + + describe('@provides annotations', () => { + beforeEach(() => { + mockIndexFile(source.replace(/@providesModule/, '@provides')); + }); + + it('extracts the module name from the header if it has a @provides annotation', () => + module.getName().then(name => expect(name).toEqual(moduleId)) + ); + + it('identifies the module as haste module', () => + module.isHaste().then(isHaste => expect(isHaste).toBe(true)) + ); + + it('does not transform the file in order to access the name', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).getName() + .then(() => expect(transformCode).not.toBeCalled()); + }); + + it('does not transform the file in order to access the haste status', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).isHaste() + .then(() => expect(transformCode).not.toBeCalled()); + }); + }); + + describe('no annotation', () => { + beforeEach(() => { + mockIndexFile('arbitrary(code);'); + }); + + it('uses the file name as module name', () => + module.getName().then(name => expect(name).toEqual(fileName)) + ); + + it('does not identify the module as haste module', () => + module.isHaste().then(isHaste => expect(isHaste).toBe(false)) + ); + + it('does not transform the file in order to access the name', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).getName() + .then(() => expect(transformCode).not.toBeCalled()); + }); + + it('does not transform the file in order to access the haste status', () => { + const transformCode = + jest.genMockFn().mockReturnValue(Promise.resolve()); + return createModule({transformCode}).isHaste() + .then(() => expect(transformCode).not.toBeCalled()); + }); + }); + }); + + describe('Code', () => { + const fileContents = 'arbitrary(code)'; + beforeEach(function() { + mockIndexFile(fileContents); + }); + + it('exposes file contents as `code` property on the data exposed by `read()`', () => + createModule().read().then(({code}) => + expect(code).toBe(fileContents)) + ); + + it('exposes file contents via the `getCode()` method', () => + createModule().getCode().then(code => + expect(code).toBe(fileContents)) + ); + }); + + describe('Custom Code Transform', () => { + let transformCode; + let transformResult; + const fileContents = 'arbitrary(code);'; + const exampleCode = ` + ${'require'}('a'); + ${'System.import'}('b'); + ${'require'}('c');`; + + beforeEach(function() { + transformResult = {code: ''}; + transformCode = jest.genMockFn() + .mockImplementation((module, sourceCode, options) => { + TransformCache.writeSync({ + filePath: module.path, + sourceCode, + transformOptions: options, + transformCacheKey, + result: transformResult, + }); + return Promise.resolve(transformResult); + }); + mockIndexFile(fileContents); + }); + + it('passes the module and file contents to the transform function when reading', () => { + const module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toBeCalledWith(module, fileContents, undefined); + }); + }); + + it('passes any additional options to the transform function when reading', () => { + const module = createModule({transformCode}); + const transformOptions = {arbitrary: Object()}; + return module.read(transformOptions) + .then(() => + expect(transformCode.mock.calls[0][2]).toBe(transformOptions) + ); + }); + + it('passes module and file contents if the file is annotated with @extern', () => { + const module = createModule({transformCode}); + const customFileContents = ` + /** + * @extern + */ + `; + mockIndexFile(customFileContents); + return module.read().then(() => { + expect(transformCode).toBeCalledWith(module, customFileContents, {extern: true}); + }); + }); + + it('passes the module and file contents to the transform for JSON files', () => { + mockPackageFile(); + const module = createJSONModule({transformCode}); + return module.read().then(() => { + expect(transformCode).toBeCalledWith(module, packageJson, {extern: true}); + }); + }); + + it('does not extend the passed options object if the file is annotated with @extern', () => { + const module = createModule({transformCode}); + const customFileContents = ` + /** + * @extern + */ + `; + mockIndexFile(customFileContents); + const options = {arbitrary: 'foo'}; + return module.read(options).then(() => { + expect(options).not.toEqual(jasmine.objectContaining({extern: true})); + expect(transformCode) + .toBeCalledWith(module, customFileContents, {...options, extern: true}); + }); + }); + + it('does not extend the passed options object for JSON files', () => { + mockPackageFile(); + const module = createJSONModule({transformCode}); + const options = {arbitrary: 'foo'}; + return module.read(options).then(() => { + expect(options).not.toEqual(jasmine.objectContaining({extern: true})); + expect(transformCode) + .toBeCalledWith(module, packageJson, {...options, extern: true}); + }); + }); + + it('uses dependencies that `transformCode` resolves to, instead of extracting them', () => { + const mockedDependencies = ['foo', 'bar']; + transformResult = { + code: exampleCode, + dependencies: mockedDependencies, + }; + const module = createModule({transformCode}); + + return module.getDependencies().then(dependencies => { + expect(dependencies).toEqual(mockedDependencies); + }); + }); + + it('forwards all additional properties of the result provided by `transformCode`', () => { + transformResult = { + code: exampleCode, + arbitrary: 'arbitrary', + dependencyOffsets: [12, 764], + map: {version: 3}, + subObject: {foo: 'bar'}, + }; + const module = createModule({transformCode}); + + return module.read().then((result) => { + expect(result).toEqual(jasmine.objectContaining(transformResult)); + }); + }); + + it('only stores dependencies if `cacheTransformResults` option is disabled', () => { + transformResult = { + code: exampleCode, + arbitrary: 'arbitrary', + dependencies: ['foo', 'bar'], + dependencyOffsets: [12, 764], + map: {version: 3}, + subObject: {foo: 'bar'}, + }; + const module = createModule({transformCode, options: { + cacheTransformResults: false, + }}); + + return module.read().then((result) => { + expect(result).toEqual({ + dependencies: ['foo', 'bar'], + }); + }); + }); + + it('stores all things if options is undefined', () => { + transformResult = { + code: exampleCode, + arbitrary: 'arbitrary', + dependencies: ['foo', 'bar'], + dependencyOffsets: [12, 764], + map: {version: 3}, + subObject: {foo: 'bar'}, + }; + const module = createModule({transformCode, options: undefined}); + + return module.read().then((result) => { + expect(result).toEqual({ ...transformResult, source: 'arbitrary(code);'}); + }); + }); + + it('exposes the transformed code rather than the raw file contents', () => { + transformResult = {code: exampleCode}; + const module = createModule({transformCode}); + return Promise.all([module.read(), module.getCode()]) + .then(([data, code]) => { + expect(data.code).toBe(exampleCode); + expect(code).toBe(exampleCode); + }); + }); + + it('exposes the raw file contents as `source` property', () => { + const module = createModule({transformCode}); + return module.read() + .then(data => expect(data.source).toBe(fileContents)); + }); + + it('exposes a source map returned by the transform', () => { + const map = {version: 3}; + transformResult = {map, code: exampleCode}; + const module = createModule({transformCode}); + return Promise.all([module.read(), module.getMap()]) + .then(([data, sourceMap]) => { + expect(data.map).toBe(map); + expect(sourceMap).toBe(map); + }); + }); + + it('caches the transform result for the same transform options', () => { + let module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(1); + // We want to check transform caching rather than shallow caching of + // Promises returned by read(). + module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('triggers a new transform for different transform options', () => { + const module = createModule({transformCode}); + return module.read({foo: 1}) + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(1); + return module.read({foo: 2}) + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(2); + }); + }); + }); + + it('triggers a new transform for different source code', () => { + let module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(1); + cache = createCache(); + mockIndexFile('test'); + module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(2); + }); + }); + }); + + it('triggers a new transform for different transform cache key', () => { + let module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(1); + transformCacheKey = 'other'; + module = createModule({transformCode}); + return module.read() + .then(() => { + expect(transformCode).toHaveBeenCalledTimes(2); + }); + }); + }); + + }); +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/index.js b/packages/metro-bundler/react-packager/src/node-haste/index.js new file mode 100644 index 00000000..5c64e01c --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/index.js @@ -0,0 +1,450 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +const Cache = require('./Cache'); +const DependencyGraphHelpers = require('./DependencyGraph/DependencyGraphHelpers'); +const HasteMap = require('./DependencyGraph/HasteMap'); +const JestHasteMap = require('jest-haste-map'); +const Module = require('./Module'); +const ModuleCache = require('./ModuleCache'); +const Polyfill = require('./Polyfill'); +const ResolutionRequest = require('./DependencyGraph/ResolutionRequest'); +const ResolutionResponse = require('./DependencyGraph/ResolutionResponse'); + +const fs = require('fs'); +const getAssetDataFromName = require('./lib/getAssetDataFromName'); +const getInverseDependencies = require('./lib/getInverseDependencies'); +const getPlatformExtension = require('./lib/getPlatformExtension'); +const isAbsolutePath = require('absolute-path'); +const os = require('os'); +const path = require('path'); +const replacePatterns = require('./lib/replacePatterns'); +const util = require('util'); + +const { + createActionEndEntry, + createActionStartEntry, + log, +} = require('../Logger'); + +import type {Options as TransformOptions} from '../JSTransformer/worker/worker'; +import type GlobalTransformCache from '../lib/GlobalTransformCache'; +import type {Reporter} from '../lib/reporting'; +import type { + Options as ModuleOptions, + TransformCode, +} from './Module'; +import type {HasteFS} from './types'; + +const ERROR_BUILDING_DEP_GRAPH = 'DependencyGraphError'; + +class DependencyGraph { + _opts: {| + assetExts: Array, + extensions: Array, + extraNodeModules: ?Object, + forceNodeFilesystemAPI: boolean, + globalTransformCache: ?GlobalTransformCache, + ignoreFilePath: (filePath: string) => boolean, + maxWorkers: ?number, + mocksPattern: mixed, + moduleOptions: ModuleOptions, + platforms: Set, + preferNativePlatform: boolean, + providesModuleNodeModules: Array, + resetCache: boolean, + roots: Array, + shouldThrowOnUnresolvedErrors: () => boolean, + transformCacheKey: string, + transformCode: TransformCode, + useWatchman: boolean, + watch: boolean, + |}; + _assetDependencies: Array; + _cache: Cache; + _haste: JestHasteMap; + _hasteFS: HasteFS; + _hasteMap: HasteMap; + _hasteMapError: ?Error; + _helpers: DependencyGraphHelpers; + _moduleCache: ModuleCache; + _reporter: Reporter; + + _loading: Promise; + + constructor({ + assetDependencies, + assetExts, + cache, + extensions, + extraNodeModules, + forceNodeFilesystemAPI, + globalTransformCache, + ignoreFilePath, + maxWorkers, + mocksPattern, + moduleOptions, + platforms, + preferNativePlatform, + providesModuleNodeModules, + resetCache, + roots, + shouldThrowOnUnresolvedErrors = () => true, + transformCacheKey, + transformCode, + useWatchman, + watch, + reporter, + }: { + assetDependencies: Array, + assetExts: Array, + cache: Cache, + extensions?: ?Array, + extraNodeModules: ?Object, + forceNodeFilesystemAPI?: boolean, + globalTransformCache: ?GlobalTransformCache, + ignoreFilePath: (filePath: string) => boolean, + maxWorkers?: ?number, + mocksPattern?: mixed, + moduleOptions: ?ModuleOptions, + platforms: Array, + preferNativePlatform: boolean, + providesModuleNodeModules: Array, + resetCache: boolean, + roots: Array, + shouldThrowOnUnresolvedErrors?: () => boolean, + transformCacheKey: string, + transformCode: TransformCode, + useWatchman?: ?boolean, + watch: boolean, + reporter: Reporter, + }) { + this._opts = { + assetExts: assetExts || [], + extensions: extensions || ['js', 'json'], + extraNodeModules, + forceNodeFilesystemAPI: !!forceNodeFilesystemAPI, + globalTransformCache, + ignoreFilePath: ignoreFilePath || (() => {}), + maxWorkers, + mocksPattern, + moduleOptions: moduleOptions || { + cacheTransformResults: true, + }, + platforms: new Set(platforms || []), + preferNativePlatform: preferNativePlatform || false, + providesModuleNodeModules, + resetCache, + roots, + shouldThrowOnUnresolvedErrors, + transformCacheKey, + transformCode, + useWatchman: useWatchman !== false, + watch: !!watch, + }; + + this._reporter = reporter; + this._cache = cache; + this._assetDependencies = assetDependencies; + this._helpers = new DependencyGraphHelpers(this._opts); + this.load(); + } + + load() { + if (this._loading) { + return this._loading; + } + + const mw = this._opts.maxWorkers; + this._haste = new JestHasteMap({ + extensions: this._opts.extensions.concat(this._opts.assetExts), + forceNodeFilesystemAPI: this._opts.forceNodeFilesystemAPI, + ignorePattern: {test: this._opts.ignoreFilePath}, + maxWorkers: typeof mw === 'number' && mw >= 1 ? mw : getMaxWorkers(), + mocksPattern: '', + name: 'react-native-packager', + platforms: Array.from(this._opts.platforms), + providesModuleNodeModules: this._opts.providesModuleNodeModules, + resetCache: this._opts.resetCache, + retainAllFiles: true, + roots: this._opts.roots, + useWatchman: this._opts.useWatchman, + watch: this._opts.watch, + }); + + const initializingPackagerLogEntry = + log(createActionStartEntry('Initializing Packager')); + this._reporter.update({type: 'dep_graph_loading'}); + this._loading = this._haste.build().then(({hasteFS}) => { + this._hasteFS = hasteFS; + const hasteFSFiles = hasteFS.getAllFiles(); + + this._moduleCache = new ModuleCache({ + cache: this._cache, + globalTransformCache: this._opts.globalTransformCache, + transformCode: this._opts.transformCode, + transformCacheKey: this._opts.transformCacheKey, + depGraphHelpers: this._helpers, + assetDependencies: this._assetDependencies, + moduleOptions: this._opts.moduleOptions, + reporter: this._reporter, + getClosestPackage: filePath => { + let {dir, root} = path.parse(filePath); + do { + const candidate = path.join(dir, 'package.json'); + if (this._hasteFS.exists(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } while (dir !== '.' && dir !== root); + return null; + } + }, this._opts.platforms); + + this._hasteMap = new HasteMap({ + files: hasteFSFiles, + extensions: this._opts.extensions, + moduleCache: this._moduleCache, + preferNativePlatform: this._opts.preferNativePlatform, + helpers: this._helpers, + platforms: this._opts.platforms, + }); + + this._haste.on('change', ({eventsQueue, hasteFS: newHasteFS}) => { + this._hasteFS = newHasteFS; + eventsQueue.forEach(({type, filePath, stat}) => + this.processFileChange(type, filePath, stat) + ); + }); + + const buildingHasteMapLogEntry = + log(createActionStartEntry('Building Haste Map')); + + return this._hasteMap.build().then( + map => { + log(createActionEndEntry(buildingHasteMapLogEntry)); + log(createActionEndEntry(initializingPackagerLogEntry)); + this._reporter.update({type: 'dep_graph_loaded'}); + return map; + }, + err => { + const error = new Error( + `Failed to build DependencyGraph: ${err.message}` + ); + /* $FlowFixMe: monkey-patching */ + error.type = ERROR_BUILDING_DEP_GRAPH; + error.stack = err.stack; + throw error; + } + ); + }); + + return this._loading; + } + + /** + * Returns a promise with the direct dependencies the module associated to + * the given entryPath has. + */ + getShallowDependencies(entryPath: string, transformOptions: mixed) { + return this._moduleCache + .getModule(entryPath) + .getDependencies(transformOptions); + } + + getWatcher() { + return this._haste; + } + + /** + * Returns the module object for the given path. + */ + getModuleForPath(entryFile: string) { + return this._moduleCache.getModule(entryFile); + } + + getAllModules() { + return this.load().then(() => this._moduleCache.getAllModules()); + } + + getDependencies({ + entryPath, + platform, + transformOptions, + onProgress, + recursive = true, + }: { + entryPath: string, + platform: string, + transformOptions: TransformOptions, + onProgress?: ?(finishedModules: number, totalModules: number) => mixed, + recursive: boolean, + }) { + return this.load().then(() => { + platform = this._getRequestPlatform(entryPath, platform); + const absPath = this._getAbsolutePath(entryPath); + const dirExists = filePath => { + try { + return fs.lstatSync(filePath).isDirectory(); + } catch (e) {} + return false; + }; + const req = new ResolutionRequest({ + dirExists, + entryPath: absPath, + extraNodeModules: this._opts.extraNodeModules, + hasteFS: this._hasteFS, + hasteMap: this._hasteMap, + helpers: this._helpers, + moduleCache: this._moduleCache, + platform, + platforms: this._opts.platforms, + preferNativePlatform: this._opts.preferNativePlatform, + }); + + const response = new ResolutionResponse({transformOptions}); + + return req.getOrderedDependencies({ + response, + transformOptions, + onProgress, + recursive, + }).then(() => response); + }); + } + + matchFilesByPattern(pattern: RegExp) { + return this.load().then(() => this._hasteFS.matchFiles(pattern)); + } + + _getRequestPlatform(entryPath: string, platform: string) { + if (platform == null) { + platform = getPlatformExtension(entryPath, this._opts.platforms); + } else if (!this._opts.platforms.has(platform)) { + throw new Error('Unrecognized platform: ' + platform); + } + return platform; + } + + _getAbsolutePath(filePath) { + if (isAbsolutePath(filePath)) { + return path.resolve(filePath); + } + + for (let i = 0; i < this._opts.roots.length; i++) { + const root = this._opts.roots[i]; + const potentialAbsPath = path.join(root, filePath); + if (this._hasteFS.exists(potentialAbsPath)) { + return path.resolve(potentialAbsPath); + } + } + + throw new NotFoundError( + 'Cannot find entry file %s in any of the roots: %j', + filePath, + this._opts.roots + ); + } + + processFileChange(type: string, filePath: string, stat: Object) { + this._moduleCache.processFileChange(type, filePath, stat); + + // This code reports failures but doesn't block recovery in the dev server + // mode. When the hasteMap is left in an incorrect state, we'll rebuild when + // the next file changes. + const resolve = () => { + if (this._hasteMapError) { + console.warn( + 'Rebuilding haste map to recover from error:\n' + + this._hasteMapError.stack + ); + this._hasteMapError = null; + + // Rebuild the entire map if last change resulted in an error. + this._loading = this._hasteMap.build(); + } else { + this._loading = this._hasteMap.processFileChange(type, filePath); + this._loading.catch(error => { + this._hasteMapError = error; + }); + } + return this._loading; + }; + + this._loading = this._loading.then(resolve, resolve); + } + + createPolyfill(options: {file: string}) { + return this._moduleCache.createPolyfill(options); + } + + getHasteMap() { + return this._hasteMap; + } + + static Cache; + static Module; + static Polyfill; + static getAssetDataFromName; + static getPlatformExtension; + static replacePatterns; + static getInverseDependencies; + +} + +Object.assign(DependencyGraph, { + Cache, + Module, + Polyfill, + getAssetDataFromName, + getPlatformExtension, + replacePatterns, + getInverseDependencies, +}); + +function NotFoundError() { + /* $FlowFixMe: monkey-patching */ + Error.call(this); + Error.captureStackTrace(this, this.constructor); + var msg = util.format.apply(util, arguments); + this.message = msg; + this.type = this.name = 'NotFoundError'; + this.status = 404; +} +util.inherits(NotFoundError, Error); + +function getMaxWorkers() { + const cores = os.cpus().length; + + if (cores <= 1) { + // oh well... + return 1; + } + if (cores <= 4) { + // don't starve the CPU while still reading reasonably rapidly + return cores - 1; + } + if (cores <= 8) { + // empirical testing showed massive diminishing returns when going over + // 4 or 5 workers on 8-core machines + return Math.floor(cores * 0.75) - 1; + } + + // pretty much guesswork + if (cores < 24) { + return Math.floor(3 / 8 * cores + 3); + } + return cores / 2; +} + +module.exports = DependencyGraph; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/AsyncTaskGroup.js b/packages/metro-bundler/react-packager/src/node-haste/lib/AsyncTaskGroup.js new file mode 100644 index 00000000..7f99b25e --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/AsyncTaskGroup.js @@ -0,0 +1,28 @@ + /** + * 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'; + +module.exports = class AsyncTaskGroup { + constructor() { + this._runningTasks = new Set(); + this._resolve = null; + this.done = new Promise(resolve => this._resolve = resolve); + } + + start(taskHandle) { + this._runningTasks.add(taskHandle); + } + + end(taskHandle) { + const runningTasks = this._runningTasks; + if (runningTasks.delete(taskHandle) && runningTasks.size === 0) { + this._resolve(); + } + } +}; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/MapWithDefaults.js b/packages/metro-bundler/react-packager/src/node-haste/lib/MapWithDefaults.js new file mode 100644 index 00000000..a264f0c2 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/MapWithDefaults.js @@ -0,0 +1,30 @@ + /** + * 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'; + +module.exports = function MapWithDefaults(factory, iterable) { + // This can't be `MapWithDefaults extends Map`, b/c the way babel transforms + // super calls in constructors: Map.call(this, iterable) throws for native + // Map objects in node 4+. + // TODO(davidaurelio) switch to a transform that does not transform classes + // and super calls, and change this into a class + + const map = iterable ? new Map(iterable) : new Map(); + const {get} = map; + map.get = key => { + if (map.has(key)) { + return get.call(map, key); + } + + const value = factory(key); + map.set(key, value); + return value; + }; + return map; +}; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getAssetDataFromName-test.js b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getAssetDataFromName-test.js new file mode 100644 index 00000000..a1e6b8cc --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getAssetDataFromName-test.js @@ -0,0 +1,127 @@ +/** + * 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.dontMock('../getPlatformExtension') + .dontMock('../getAssetDataFromName'); + +var getAssetDataFromName = require('../getAssetDataFromName'); + +describe('getAssetDataFromName', () => { + it('should get data from name', () => { + expect(getAssetDataFromName('a/b/c.png')).toEqual({ + resolution: 1, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: null, + }); + + expect(getAssetDataFromName('a/b/c@1x.png')).toEqual({ + resolution: 1, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: null, + }); + + expect(getAssetDataFromName('a/b/c@2.5x.png')).toEqual({ + resolution: 2.5, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: null, + }); + + expect(getAssetDataFromName('a/b/c.ios.png')).toEqual({ + resolution: 1, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: 'ios', + }); + + expect(getAssetDataFromName('a/b/c@1x.ios.png')).toEqual({ + resolution: 1, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: 'ios', + }); + + expect(getAssetDataFromName('a/b/c@2.5x.ios.png')).toEqual({ + resolution: 2.5, + assetName: 'a/b/c.png', + type: 'png', + name: 'c', + platform: 'ios', + }); + + expect(getAssetDataFromName('a/b /c.png')).toEqual({ + resolution: 1, + assetName: 'a/b /c.png', + type: 'png', + name: 'c', + platform: null, + }); + }); + + describe('resolution extraction', () => { + it('should extract resolution simple case', () => { + var data = getAssetDataFromName('test@2x.png'); + expect(data).toEqual({ + assetName: 'test.png', + resolution: 2, + type: 'png', + name: 'test', + platform: null, + }); + }); + + it('should default resolution to 1', () => { + var data = getAssetDataFromName('test.png'); + expect(data).toEqual({ + assetName: 'test.png', + resolution: 1, + type: 'png', + name: 'test', + platform: null, + }); + }); + + it('should support float', () => { + var data = getAssetDataFromName('test@1.1x.png'); + expect(data).toEqual({ + assetName: 'test.png', + resolution: 1.1, + type: 'png', + name: 'test', + platform: null, + }); + + data = getAssetDataFromName('test@.1x.png'); + expect(data).toEqual({ + assetName: 'test.png', + resolution: 0.1, + type: 'png', + name: 'test', + platform: null, + }); + + data = getAssetDataFromName('test@0.2x.png'); + expect(data).toEqual({ + assetName: 'test.png', + resolution: 0.2, + type: 'png', + name: 'test', + platform: null, + }); + }); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getInverseDependencies-test.js b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getInverseDependencies-test.js new file mode 100644 index 00000000..cbb31a78 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getInverseDependencies-test.js @@ -0,0 +1,55 @@ +/** + * 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.dontMock('../getInverseDependencies'); + +const getInverseDependencies = require('../getInverseDependencies'); + +describe('getInverseDependencies', () => { + it('', () => { + const module1 = createModule('module1', ['module2', 'module3']); + const module2 = createModule('module2', ['module3', 'module4']); + const module3 = createModule('module3', ['module4']); + const module4 = createModule('module4', []); + + const modulePairs = { + 'module1': [['module2', module2], ['module3', module3]], + 'module2': [['module3', module3], ['module4', module4]], + 'module3': [['module4', module4]], + 'module4': [], + }; + + const resolutionResponse = { + dependencies: [module1, module2, module3, module4], + getResolvedDependencyPairs: (module) => { + return modulePairs[module.hash()]; + }, + }; + + const dependencies = getInverseDependencies(resolutionResponse); + const actual = // jest can't compare maps and sets + Array.from(dependencies.entries()) + .map(([key, value]) => [key, Array.from(value)]); + + expect(actual).toEqual([ + [module2, [module1]], + [module3, [module1, module2]], + [module4, [module2, module3]], + ]); + }); +}); + +function createModule(name, dependencies) { + return { + hash: () => name, + getName: () => Promise.resolve(name), + getDependencies: () => Promise.resolve(dependencies), + }; +} diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getPlatformExtension-test.js b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getPlatformExtension-test.js new file mode 100644 index 00000000..ac601236 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/__tests__/getPlatformExtension-test.js @@ -0,0 +1,33 @@ +/** + * 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.dontMock('../getPlatformExtension'); + +var getPlatformExtension = require('../getPlatformExtension'); + +describe('getPlatformExtension', function() { + it('should get platform ext', function() { + expect(getPlatformExtension('a.ios.js')).toBe('ios'); + expect(getPlatformExtension('a.android.js')).toBe('android'); + expect(getPlatformExtension('/b/c/a.ios.js')).toBe('ios'); + expect(getPlatformExtension('/b/c.android/a.ios.js')).toBe('ios'); + expect(getPlatformExtension('/b/c/a@1.5x.ios.png')).toBe('ios'); + expect(getPlatformExtension('/b/c/a@1.5x.lol.png')).toBe(null); + expect(getPlatformExtension('/b/c/a.lol.png')).toBe(null); + }); + + it('should optionally accept supported platforms', function() { + expect(getPlatformExtension('a.ios.js', new Set(['ios']))).toBe('ios'); + expect(getPlatformExtension('a.android.js', new Set(['android']))).toBe('android'); + expect(getPlatformExtension('/b/c/a.ios.js', new Set(['ios', 'android']))).toBe('ios'); + expect(getPlatformExtension('a.ios.js', new Set(['ubuntu']))).toBe(null); + expect(getPlatformExtension('a.ubuntu.js', new Set(['ubuntu']))).toBe('ubuntu'); + }); +}); diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/getAssetDataFromName.js b/packages/metro-bundler/react-packager/src/node-haste/lib/getAssetDataFromName.js new file mode 100644 index 00000000..04051175 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/getAssetDataFromName.js @@ -0,0 +1,56 @@ + /** + * 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'; + +const getPlatformExtension = require('./getPlatformExtension'); +const path = require('path'); + +function getAssetDataFromName(filename, platforms) { + const ext = path.extname(filename); + const platformExt = getPlatformExtension(filename, platforms); + + let pattern = '@([\\d\\.]+)x'; + if (platformExt != null) { + pattern += '(\\.' + platformExt + ')?'; + } + pattern += '\\' + ext + '$'; + const re = new RegExp(pattern); + + const match = filename.match(re); + let resolution; + + if (!(match && match[1])) { + resolution = 1; + } else { + resolution = parseFloat(match[1], 10); + if (isNaN(resolution)) { + resolution = 1; + } + } + + let assetName; + if (match) { + assetName = filename.replace(re, ext); + } else if (platformExt != null) { + assetName = filename.replace(new RegExp(`\\.${platformExt}\\${ext}`), ext); + } else { + assetName = filename; + } + assetName = decodeURIComponent(assetName); + + return { + resolution: resolution, + assetName: assetName, + type: ext.slice(1), + name: path.basename(assetName, ext), + platform: platformExt, + }; +} + +module.exports = getAssetDataFromName; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/getInverseDependencies.js b/packages/metro-bundler/react-packager/src/node-haste/lib/getInverseDependencies.js new file mode 100644 index 00000000..d2aa7517 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/getInverseDependencies.js @@ -0,0 +1,42 @@ +/** + * 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'; + +function resolveModuleRequires(resolutionResponse, module) { + const pairs = resolutionResponse.getResolvedDependencyPairs(module); + return pairs + ? pairs.map(([, dependencyModule]) => dependencyModule) + : []; +} + +function getModuleDependents(cache, module) { + let dependents = cache.get(module); + if (!dependents) { + dependents = new Set(); + cache.set(module, dependents); + } + return dependents; +} + +/** + * Returns an object that indicates in which module each module is required. + */ +function getInverseDependencies(resolutionResponse) { + const cache = new Map(); + + resolutionResponse.dependencies.forEach(module => { + resolveModuleRequires(resolutionResponse, module).forEach(dependency => { + getModuleDependents(cache, dependency).add(module); + }); + }); + + return cache; +} + +module.exports = getInverseDependencies; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/getPlatformExtension.js b/packages/metro-bundler/react-packager/src/node-haste/lib/getPlatformExtension.js new file mode 100644 index 00000000..6f505833 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/getPlatformExtension.js @@ -0,0 +1,28 @@ +/** + * 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'; + +const SUPPORTED_PLATFORM_EXTS = new Set([ + 'android', + 'ios', + 'web', +]); + +// Extract platform extension: index.ios.js -> ios +function getPlatformExtension(file, platforms = SUPPORTED_PLATFORM_EXTS) { + const last = file.lastIndexOf('.'); + const secondToLast = file.lastIndexOf('.', last - 1); + if (secondToLast === -1) { + return null; + } + const platform = file.substring(secondToLast + 1, last); + return platforms.has(platform) ? platform : null; +} + +module.exports = getPlatformExtension; diff --git a/packages/metro-bundler/react-packager/src/node-haste/lib/replacePatterns.js b/packages/metro-bundler/react-packager/src/node-haste/lib/replacePatterns.js new file mode 100644 index 00000000..54f34b4d --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/lib/replacePatterns.js @@ -0,0 +1,14 @@ +/** + * 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'; + +exports.IMPORT_RE = /(\bimport\s+(?:[^'"]+\s+from\s+)??)(['"])([^'"]+)(\2)/g; +exports.EXPORT_RE = /(\bexport\s+(?:[^'"]+\s+from\s+)??)(['"])([^'"]+)(\2)/g; +exports.REQUIRE_RE = /(\brequire\s*?\(\s*?)(['"`])([^'"`]+)(\2\s*?\))/g; diff --git a/packages/metro-bundler/react-packager/src/node-haste/types.js b/packages/metro-bundler/react-packager/src/node-haste/types.js new file mode 100644 index 00000000..59ddf0e9 --- /dev/null +++ b/packages/metro-bundler/react-packager/src/node-haste/types.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2013-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. + * + * @flow + */ + +'use strict'; + +// TODO(cpojer): Create a jest-types repo. +export type HasteFS = { + exists(filePath: string): boolean, + getAllFiles(): Array, + matchFiles(pattern: RegExp | string): Array, +}; diff --git a/packages/metro-bundler/rn-cli.config.js b/packages/metro-bundler/rn-cli.config.js new file mode 100644 index 00000000..3f6f4fd5 --- /dev/null +++ b/packages/metro-bundler/rn-cli.config.js @@ -0,0 +1,46 @@ +/** + * 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. + * + * React Native CLI configuration file + */ +'use strict'; + +const blacklist = require('./blacklist'); +const path = require('path'); + +module.exports = { + getProjectRoots() { + return this._getRoots(); + }, + + getAssetExts() { + return []; + }, + + getBlacklistRE() { + return blacklist(); + }, + + _getRoots() { + // match on either path separator + if (__dirname.match(/node_modules[\/\\]react-native[\/\\]packager$/)) { + // packager is running from node_modules of another project + return [path.resolve(__dirname, '../../..')]; + } else if (__dirname.match(/Pods\/React\/packager$/)) { + // packager is running from node_modules of another project + return [path.resolve(__dirname, '../../..')]; + } else { + return [path.resolve(__dirname, '..')]; + } + }, + + getTransformModulePath() { + return require.resolve('./transformer'); + }, + +}; diff --git a/packages/metro-bundler/transformer.js b/packages/metro-bundler/transformer.js new file mode 100644 index 00000000..7ce94a0a --- /dev/null +++ b/packages/metro-bundler/transformer.js @@ -0,0 +1,142 @@ +/** + * 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. + * + * Note: This is a fork of the fb-specific transform.js + */ +'use strict'; + +const babel = require('babel-core'); +const externalHelpersPlugin = require('babel-plugin-external-helpers'); +const fs = require('fs'); +const generate = require('babel-generator').default; +const inlineRequiresPlugin = require('babel-preset-fbjs/plugins/inline-requires'); +const json5 = require('json5'); +const makeHMRConfig = require('babel-preset-react-native/configs/hmr'); +const path = require('path'); +const resolvePlugins = require('babel-preset-react-native/lib/resolvePlugins'); + +const {compactMapping} = require('./react-packager/src/Bundler/source-map'); + +/** + * Return a memoized function that checks for the existence of a + * project level .babelrc file, and if it doesn't exist, reads the + * default RN babelrc file and uses that. + */ +const getBabelRC = (function() { + let babelRC = null; + + return function _getBabelRC(projectRoots) { + if (babelRC !== null) { + return babelRC; + } + + babelRC = { plugins: [] }; // empty babelrc + + // Let's look for the .babelrc in the first project root. + // In the future let's look into adding a command line option to specify + // this location. + let projectBabelRCPath; + if (projectRoots && projectRoots.length > 0) { + projectBabelRCPath = path.resolve(projectRoots[0], '.babelrc'); + } + + // If a .babelrc file doesn't exist in the project, + // use the Babel config provided with react-native. + if (!projectBabelRCPath || !fs.existsSync(projectBabelRCPath)) { + babelRC = json5.parse( + fs.readFileSync( + path.resolve(__dirname, 'react-packager', 'rn-babelrc.json')) + ); + + // Require the babel-preset's listed in the default babel config + babelRC.presets = babelRC.presets.map((preset) => require('babel-preset-' + preset)); + babelRC.plugins = resolvePlugins(babelRC.plugins); + } else { + // if we find a .babelrc file we tell babel to use it + babelRC.extends = projectBabelRCPath; + } + + return babelRC; + }; +})(); + +/** + * Given a filename and options, build a Babel + * config object with the appropriate plugins. + */ +function buildBabelConfig(filename, options) { + const babelRC = getBabelRC(options.projectRoots); + + const extraConfig = { + code: false, + filename, + }; + + let config = Object.assign({}, babelRC, extraConfig); + + // Add extra plugins + const extraPlugins = [externalHelpersPlugin]; + + var inlineRequires = options.inlineRequires; + var blacklist = inlineRequires && inlineRequires.blacklist; + if (inlineRequires && !(blacklist && filename in blacklist)) { + extraPlugins.push(inlineRequiresPlugin); + } + + config.plugins = extraPlugins.concat(config.plugins); + + if (options.hot) { + const hmrConfig = makeHMRConfig(options, filename); + config = Object.assign({}, config, hmrConfig); + } + + return Object.assign({}, babelRC, config); +} + +function transform(src, filename, options) { + options = options || {}; + + const OLD_BABEL_ENV = process.env.BABEL_ENV; + process.env.BABEL_ENV = options.dev ? 'development' : 'production'; + + try { + const babelConfig = buildBabelConfig(filename, options); + const {ast} = babel.transform(src, babelConfig); + const result = generate(ast, { + comments: false, + compact: false, + filename, + sourceFileName: filename, + sourceMaps: true, + }, src); + + return { + ast, + code: result.code, + filename, + map: options.generateSourceMaps ? result.map : result.rawMappings.map(compactMapping), + }; + } finally { + process.env.BABEL_ENV = OLD_BABEL_ENV; + } +} + +module.exports = function(data, callback) { + let result; + try { + result = transform(data.sourceCode, data.filename, data.options); + } catch (e) { + callback(e); + return; + } + + callback(null, result); +}; + +// export for use in jest +module.exports.transform = transform;