├── .nvmrc ├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── .gitignore ├── .github └── workflows │ └── push.yml ├── index.test.js ├── LICENSE ├── .eslintrc.json ├── package.json ├── README.md └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Dependency directories 20 | node_modules/ 21 | 22 | # Yarn Integrity file 23 | .yarn-integrity 24 | 25 | ### Jest 26 | coverage 27 | 28 | ### JetBrains 29 | .idea 30 | 31 | ### VSCode 32 | .vscode 33 | 34 | ### macOS 35 | .DS_Store 36 | .AppleDouble 37 | .LSOverride 38 | 39 | # Icon must end with two \r 40 | Icon 41 | 42 | # Thumbnails 43 | ._* 44 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: push 3 | 4 | jobs: 5 | test-and-release: 6 | name: Test and Release 7 | runs-on: ubuntu-latest 8 | steps: 9 | # Checkout the repo at the push ref 10 | - uses: actions/checkout@v1 11 | # Install deps 12 | - name: Install 13 | run: yarn 14 | # Run tests 15 | - name: Test 16 | run: yarn test 17 | # Attempt release if we're on master 18 | - name: Release 19 | if: github.ref == 'refs/heads/master' 20 | run: yarn run release-ci 21 | env: 22 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | const { generateLinkedDependenciesWatchFolders } = require('./'); 2 | 3 | test('generateLinkedDependenciesWatchFolders properly examines existing project config', () => { 4 | expect(generateLinkedDependenciesWatchFolders()).toEqual([]); 5 | expect(generateLinkedDependenciesWatchFolders(['a'])).toEqual(['a']); 6 | expect(generateLinkedDependenciesWatchFolders([], ['a'])).toEqual(['a']); 7 | expect(generateLinkedDependenciesWatchFolders(['b'], ['a'])).toEqual([ 8 | 'b', 9 | 'a', 10 | ]); 11 | expect(generateLinkedDependenciesWatchFolders(['b'], ['a'], {})).toEqual([ 12 | 'b', 13 | 'a', 14 | ]); 15 | expect( 16 | generateLinkedDependenciesWatchFolders(['b'], ['a'], { 17 | watchFolders: [], 18 | }), 19 | ).toEqual(['b', 'a']); 20 | expect( 21 | generateLinkedDependenciesWatchFolders(['b'], ['a'], { 22 | watchFolders: ['c'], 23 | }), 24 | ).toEqual(['b', 'a', 'c']); 25 | }); 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Carimus LLC 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest/globals": true 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "standard", 8 | "prettier", 9 | "prettier/standard" 10 | ], 11 | "parser": "babel-eslint", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "experimentalObjectRestSpread": true, 15 | "jsx": false, 16 | "modules": false 17 | }, 18 | "ecmaVersion": 7, 19 | "sourceType": "script" 20 | }, 21 | "plugins": ["babel", "jest"], 22 | "rules": { 23 | "arrow-parens": ["error", "always"], 24 | "camelcase": "error", 25 | "curly": ["error", "all"], 26 | "jest/no-disabled-tests": "warn", 27 | "jest/no-focused-tests": "warn", 28 | "jest/no-identical-title": "warn", 29 | "jest/valid-expect": "warn", 30 | "max-len": [ 31 | "error", 32 | { 33 | "code": 140, 34 | "ignoreUrls": true 35 | } 36 | ], 37 | "multiline-comment-style": ["error", "starred-block"], 38 | "no-confusing-arrow": "error", 39 | "no-console": "error", 40 | "no-undefined": "error", 41 | "no-var": "error" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@carimus/metro-symlinked-deps", 3 | "version": "0.0.0-development", 4 | "description": "Metro bundler configuration utilities to workaround a lack of symlink support.", 5 | "license": "MIT", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "jest --passWithNoTests", 9 | "test:watch": "yarn run test --watch", 10 | "lint": "eslint '{*,{src,docs}/**/*,__{tests,mocks,support}__/**/*}.{js,jsx}' || true", 11 | "pretty": "prettier --write '{*,{src,docs,.github}/**/*,__{tests,mocks,support}__/**/*}.{json,md,yml,js,jsx}'", 12 | "fixcode": "yarn run pretty", 13 | "semantic-release": "semantic-release", 14 | "release": "yarn run semantic-release", 15 | "release-ci": "echo 'unsafe-perm = true' > ./.npmrc && yarn run semantic-release && rm -rf ./.npmrc", 16 | "commit": "git-cz" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/Carimus/metro-symlinked-deps/issues" 20 | }, 21 | "repository": "https://github.com/Carimus/metro-symlinked-deps", 22 | "homepage": "https://github.com/Carimus/metro-symlinked-deps#readme", 23 | "private": false, 24 | "keywords": [ 25 | "metro", 26 | "react-native", 27 | "jest-haste-map" 28 | ], 29 | "husky": { 30 | "hooks": { 31 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 32 | "pre-commit": "lint-staged" 33 | } 34 | }, 35 | "lint-staged": { 36 | "{*,{src,docs}/**/*,__{tests,mocks,support}__/**/*}.{js,jsx}": [ 37 | "prettier --write", 38 | "eslint", 39 | "git add" 40 | ], 41 | "{*,{src,docs,.github}/**/*,__{tests,mocks,support}__/**/*}.{json,md,yml}": [ 42 | "prettier --write", 43 | "git add" 44 | ] 45 | }, 46 | "config": { 47 | "commitizen": { 48 | "path": "./node_modules/cz-conventional-changelog" 49 | } 50 | }, 51 | "commitlint": { 52 | "extends": [ 53 | "@commitlint/config-conventional" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "@commitlint/cli": "^8.2.0", 58 | "@commitlint/config-conventional": "^8.2.0", 59 | "babel-eslint": "^10.0.3", 60 | "commitizen": "^4.0.3", 61 | "cz-conventional-changelog": "^3.0.2", 62 | "eslint": "^6.4.0", 63 | "eslint-config-prettier": "^6.3.0", 64 | "eslint-config-standard": "^14.1.0", 65 | "eslint-plugin-babel": "^5.3.0", 66 | "eslint-plugin-import": "^2.18.2", 67 | "eslint-plugin-jest": "^22.17.0", 68 | "eslint-plugin-node": "^10.0.0", 69 | "eslint-plugin-promise": "^4.2.1", 70 | "eslint-plugin-standard": "^4.0.1", 71 | "husky": "^3.0.5", 72 | "jest": "^24.9.0", 73 | "lint-staged": "^9.3.0", 74 | "prettier": "^1.18.2", 75 | "semantic-release": "^15.13.24" 76 | }, 77 | "dependencies": { 78 | "chalk": "^2.4.2", 79 | "escape-string-regexp": "^2.0.0", 80 | "get-dev-paths": "^0.1.1", 81 | "metro-config": "^0.56.0", 82 | "ramda": "^0.26.1" 83 | }, 84 | "peerDependencies": {}, 85 | "engines": { 86 | "node": ">=8" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@carimus/metro-symlinked-deps` 2 | 3 | Utilities to customize the [`metro`](https://github.com/facebook/metro) bundler configuration in order to workaround 4 | its lack of support for symlinks. 5 | 6 | The primary use case for this package is to support development on react native dependencies using `yarn link` or 7 | `npm link`. 8 | 9 | ## Motivation 10 | 11 | Facebook's [`metro`](https://github.com/facebook/metro) bundler used by React Native 12 | [doesn't support symlinks](https://github.com/facebook/metro/issues/1) which is a huge hindrance in the ability to 13 | share code locally. 14 | 15 | It's related and dependent on [this issue with `jest`](https://github.com/facebook/jest/pull/7549) since `metro` 16 | uses `jest-haste-map` internally to track and watch file changes. 17 | 18 | The general process for developing on a dependency that is sharing components with the main app would be 19 | to use `yarn link` / `npm link` to symlink the dependency into the app's `node_modules`. Since Metro ignores symlinks 20 | though, it simply doesn't work out of the box with metro. There's mountains of workarounds to this that work to varying 21 | degrees. This is the one that worked for us that we're going to re-use until it's unnecessary. 22 | 23 | ## Usage 24 | 25 | Install as a dev dependency using `npm` or `yarn`: 26 | 27 | ```shell script 28 | yarn add --dev @carimus/metro-symlinked-deps 29 | ``` 30 | 31 | ### Option 1: Automatic 32 | 33 | If you don't need greater control of the `resolver.blacklistRE` outside of adding additional paths or expressions to 34 | the list, you can safely use the single `applyConfigForLinkedDependencies` function which will use `metro-config`'s 35 | `mergeConfig` to merge in the configuration updates required for the `resolver.blacklistRE` and `watchFolders`. 36 | 37 | 1. Modify your `metro.config.js` (creating it if it doesn't exist, or converting your `metro.config.json` to 38 | `metro.config.js` if its present) to require and call `applyConfigForLinkedDependencies` on your existing 39 | configuration: 40 | 41 | ```javascript 42 | const { 43 | applyConfigForLinkedDependencies, 44 | } = require('@carimus/metro-symlinked-deps'); 45 | 46 | module.exports = applyConfigForLinkedDependencies( 47 | { 48 | /* Your existing configuration, optional */ 49 | }, 50 | { 51 | /* Options to pass to applyConfigForLinkedDependencies, optional */ 52 | }, 53 | ); 54 | ``` 55 | 56 | `applyConfigForLinkedDependencies` takes the following options: 57 | 58 | - `projectRoot` (`string`, optional **but recommended**): The root of the metro bundled project. If not provided, it 59 | will be detected assuming the current `process.cwd()` is the project root. It's recommended to explicitly provide 60 | this to avoid detection issues. 61 | - `blacklistLinkedModules` (`string[]`, defaults to `[]`): a list of modules to blacklist/ignore if they show up in 62 | any linked dependencies' `node_modules`. If you get naming collisions for certain modules, add those modules 63 | by name here and restart the bundler using `--reset-cache`. A common one is `react-native` which will typically 64 | show up as a dev dependency in react native packages since it's used in tests. 65 | - `blacklistDirectories` (`string[]`, defaults to `[]`): a list of absolute or relative (to `projectRoot`) directories 66 | that should be blacklisted in addition to the directories determined via `blacklistLinkedModules`. 67 | - `resolveBlacklistDirectoriesSymlinks` (`boolean`, defaults to `true`): whether or not to resolve symlinks when 68 | processing `blacklistDirectories`. 69 | - `additionalWatchFolders` (`string[]`, defaults to `[]`): a list of additional absolute paths to watch, merged 70 | directly into the `watchFolders` option. 71 | - `resolveAdditionalWatchFoldersSymlinks` (`boolean`, defaults to `true`): whether or not to resolve symlinks when 72 | processing `additionalWatchFolders`. 73 | - `resolveNodeModulesAtRoot` (`boolean`, defaults to `false`): Set this to `true` to set up a Proxy for 74 | `resolver.extraNodeModules` in order to ensure that all modules (even the ones required by linked dependencies or 75 | any other out-of-root watch folders) will resolve to the project root's `node_modules` directory. This is primarily 76 | useful if the linked dependencies rely on the presence of peerDependencies installed in the project root. 77 | - `silent` (`boolean`, defaults to `false`): Set this to `true` to suppress warning output in the bundler that shows 78 | up when linked dependencies are detected. 79 | - `debug` (`boolean`, defaults to `false`): Set this to `true` to log out valuable debug information like the final 80 | merged metro configuration. 81 | 82 | #### Example 83 | 84 | This setup should work for an out of the box react-native 0.60+ project: 85 | 86 | ```javascript 87 | const { 88 | applyConfigForLinkedDependencies, 89 | } = require('@carimus/metro-symlinked-deps'); 90 | 91 | module.exports = applyConfigForLinkedDependencies( 92 | { 93 | transformer: { 94 | getTransformOptions: async () => ({ 95 | transform: { 96 | experimentalImportSupport: false, 97 | inlineRequires: false, 98 | }, 99 | }), 100 | }, 101 | }, 102 | { 103 | projectRoot: __dirname, 104 | blacklistLinkedModules: ['react-native'], 105 | }, 106 | ); 107 | ``` 108 | 109 | ### Option 2: Manual 110 | 111 | TODO 112 | 113 | ## Caveats 114 | 115 | - At the time of writing the blacklist approach appears to fix the naming collision error however it requires that 116 | the developer knows which packages are in-common and that they provide that list to this package in order to 117 | generate the regular expression 118 | - The naming collision doesn't appear to occur for ALL in-common packages. It's not clear if it also considers 119 | versions too, though that would make sense. 120 | 121 | ## How it works 122 | 123 | This is a workaround and as such it was built by incrementally addressing errors that show up. 124 | 125 | ### Error #1: Module not found 126 | 127 | Out of the box, if you try to use a symlinked dependency, you get the following error from the bundler when it first 128 | builds the bundle (not on during the transform step): 129 | 130 | ``` 131 | error: bundling failed: Error: Unable to resolve module `your-symlinked-module` from `/path/in/project/that/requires/the/module.js`: Module `your-symlinked-module` does not exist in the Haste module map 132 | 133 | This might be related to https://github.com/facebook/react-native/issues/4968 134 | To resolve try the following: 135 | 1. Clear watchman watches: `watchman watch-del-all`. 136 | 2. Delete the `node_modules` folder: `rm -rf node_modules && npm install`. 137 | 3. Reset Metro Bundler cache: `rm -rf /tmp/metro-bundler-cache-*` or `npm start -- --reset-cache`. 138 | 4. Remove haste cache: `rm -rf /tmp/haste-map-react-native-packager-*`. 139 | ``` 140 | 141 | Not extremely helpful but what's happening here is `metro` is just outright ignoring the symlink and as such, your 142 | module is invisible to it. 143 | 144 | The workaround here provided by [`aleclarson`](https://github.com/aleclarson) in 145 | [this comment on the `metro` issue](https://github.com/facebook/metro/issues/1#issuecomment-421628147) is to 146 | use his home-grown `get-dev-paths` package which searches `node_modules` for any symlinked dependencies that 147 | are referenced as `dependencies` in your `package.json`, resolve those links to their real dependencies, and then 148 | tell metro to also watch those real directories. 149 | 150 | ### Error #2: Haste module naming collision 151 | 152 | That works great with one important caveat: if your linked dependency has any installed dependencies in its 153 | `node_modules` that are identical to any installed dependencies in the root project, you get the following error 154 | (this error names `react-native` as the common dependency). 155 | 156 | ``` 157 | jest-haste-map: Haste module naming collision: react-native 158 | The following files share their name; please adjust your hasteImpl: 159 | * /node_modules/react-native/package.json 160 | * /../../your-symlinked-module/node_modules/react-native/package.json 161 | ``` 162 | 163 | When your dependency is installed legitimately (and not linked) any common dependencies are automatically deduped 164 | during the install (during `yarn install` or `npm install`) and `metro` (or rather `jest-haste-map`) seems to rely 165 | on this behaviour and can't identify the fact that the two packages are not conflicting with eachother and are 166 | legitimately identical. There's unfortunately no way to tell `metro` this is the case and that it should, as an 167 | example, prefer the version of the code in the root project's `node_modules` so instead we have to manually construct 168 | a blacklist of the in-common packages in the linked dependency, construct a regular expression from that, and 169 | hand that regular expression to the `blacklistRE` option of the metro bundler's `resolver` config. 170 | 171 | ## TODO 172 | 173 | - [ ] Remove `resolveNodeModulesAtRoot` and replace with `nodeModulesResolutionStrategy` with three options: 174 | - `null`: default, don't apply any `extraNodeModules` config 175 | - `'peers'`: apply `extraNodeModules` that will automatically detect peer dependencies in linked deps and ensure 176 | those are resolved in the project root while allowing all other dependencies to resolve naturally. 177 | - `'root'`: apply `extraNodeModules` that will force all node modules to resolve in the project root, equivelant 178 | to `resolveNodeModulesAtRoot` being set to `true` currently. 179 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const util = require('util'); 6 | const getDevPaths = require('get-dev-paths'); 7 | const { mergeConfig: metroMergeConfig } = require('metro-config'); 8 | const generateMetroConfigBlacklistRE = require('metro-config/src/defaults/blacklist'); 9 | const escapeForRegExp = require('escape-string-regexp'); 10 | const chalk = require('chalk'); 11 | const { has } = require('ramda'); 12 | 13 | /** 14 | * Wraps metro-config's mergeConfig function in order to remove the `symbolicator` field that's 15 | * improperly added by it and that metro complains about if present. 16 | * 17 | * We only remove it if it's empty in case it magically does have some meaning to metro and it's incorrectly 18 | * complaining about it. 19 | * 20 | * @see https://github.com/facebook/metro/issues/452 21 | * 22 | * @param args All args are simply forwarded to `metro-config`'s `mergeConfig`. 23 | * @return {object} 24 | */ 25 | function mergeConfig(...args) { 26 | const mergedConfig = metroMergeConfig(...args); 27 | 28 | // We need to remove the invalid `symbolicator` config key if it's present and empty. 29 | if ( 30 | mergedConfig.symbolicator && 31 | Object.values(mergedConfig.symbolicator).length === 0 32 | ) { 33 | delete mergedConfig.symbolicator; 34 | } 35 | 36 | return mergedConfig; 37 | } 38 | 39 | /** 40 | * Attempt to infer the project root assuming the bundler is being run from 41 | * the root of the project. 42 | * 43 | * @return {string} 44 | */ 45 | function inferProjectRoot() { 46 | return process.cwd(); 47 | } 48 | 49 | /** 50 | * Resolve a list of directories relative to the root of the project (if they're not already absolute paths). 51 | * 52 | * @param {string} projectRoot 53 | * @param {string[]} paths 54 | * @param {boolean} resolveSymlinks 55 | */ 56 | function resolvePaths(projectRoot, paths, resolveSymlinks = true) { 57 | return paths.map((possibleRelativePath) => { 58 | const absolutePath = path.resolve(projectRoot, possibleRelativePath); 59 | return resolveSymlinks ? fs.realpathSync(absolutePath) : absolutePath; 60 | }); 61 | } 62 | 63 | /** 64 | * Resolve all detected linked directories to unique absolute paths without a trailing slash. 65 | * 66 | * @param {string} projectRoot 67 | */ 68 | function resolveDevPaths(projectRoot) { 69 | return Array.from( 70 | new Set( 71 | getDevPaths(projectRoot) 72 | .map((linkPath) => { 73 | return `${fs.realpathSync(linkPath)}`.replace(/\/+$/, ''); 74 | }) 75 | .filter((absLinkPath) => !!absLinkPath), 76 | ), 77 | ); 78 | } 79 | 80 | /** 81 | * Generates the matching group that will match the directory and all files within it of dependency that is listed in 82 | * `blacklistLinkedModules` that appears in the `node_modules` directory of any resolved dev path (i.e. symlinked 83 | * dependency). 84 | * 85 | * Returns null if there are no `resolvedDevPaths` or `blacklistLinkedModules` 86 | * 87 | * @param {string[]=} resolvedDevPaths A list of resolved, real, absolute paths to the dependencies that are linked 88 | * in node_modules. 89 | * @param {string[]=} blacklistLinkedModules A list of node_modules to blacklist within linked deps. 90 | * @param {string[]=} resolvedBlacklistDirectories Additional directories to blacklist (already resolved to absolute 91 | * paths) 92 | * @return {null|RegExp} A regular expression that will match all ignored directories and all of their contents. 93 | * Ignored directories will be the blacklisted modules within node_modules/ within each linked dependency as well 94 | * as any explicitly defined via `resolvedBlacklistDirectories`. Will return null if there's no directories 95 | * to ignore. 96 | */ 97 | function generateBlacklistGroupForLinkedModules( 98 | resolvedDevPaths = [], 99 | blacklistLinkedModules = [], 100 | resolvedBlacklistDirectories = [], 101 | ) { 102 | const ignoreDirPatterns = []; 103 | 104 | if (resolvedDevPaths.length > 0 && blacklistLinkedModules.length > 0) { 105 | const escapedJoinedDevPaths = resolvedDevPaths 106 | .map(escapeForRegExp) 107 | .join('|'); 108 | const escapedJoinedModules = blacklistLinkedModules 109 | .map(escapeForRegExp) 110 | .join('|'); 111 | const devPathsMatchingGroup = `(${escapedJoinedDevPaths})`; 112 | const modulesMatchingGroup = `(${escapedJoinedModules})`; 113 | ignoreDirPatterns.push( 114 | `${devPathsMatchingGroup}\\/node_modules\\/${modulesMatchingGroup}`, 115 | ); 116 | } 117 | 118 | if (resolvedBlacklistDirectories.length > 0) { 119 | ignoreDirPatterns.push( 120 | ...resolvedBlacklistDirectories.map(escapeForRegExp), 121 | ); 122 | } 123 | 124 | if (ignoreDirPatterns.length > 0) { 125 | const ignoreDirsGroup = `(${ignoreDirPatterns.join('|')})`; 126 | return new RegExp(`(${ignoreDirsGroup}(/.*|))`); 127 | } 128 | 129 | return null; 130 | } 131 | 132 | /** 133 | * Generate a resolver config containing the `blacklistRE` option if there are linked dep node_modules that need 134 | * to be blacklisted as well as any directories that were explicitly blacklisted via config. 135 | * 136 | * @param {string[]=} resolvedDevPaths A list of resolved, real, absolute paths to the dependencies that are linked 137 | * in node_modules. 138 | * @param {string[]=} blacklistLinkedModules A list of node_modules to blacklist within linked deps. 139 | * @param {string[]=} resolvedBlacklistDirectories Additional directories to blacklist (already resolved to absolute 140 | * paths) 141 | * @return {{}|{blacklistRE: RegExp}} 142 | */ 143 | function generateLinkedDependenciesResolverConfig( 144 | resolvedDevPaths = [], 145 | blacklistLinkedModules = [], 146 | resolvedBlacklistDirectories = [], 147 | ) { 148 | const blacklistGroup = generateBlacklistGroupForLinkedModules( 149 | resolvedDevPaths, 150 | blacklistLinkedModules, 151 | resolvedBlacklistDirectories, 152 | ); 153 | 154 | if (blacklistGroup) { 155 | return { 156 | blacklistRE: generateMetroConfigBlacklistRE([blacklistGroup]), 157 | }; 158 | } 159 | 160 | return {}; 161 | } 162 | 163 | /** 164 | * Generate a resolver config that will resolve all node modules from the root of the project. 165 | * 166 | * @param {string} projectRoot 167 | * @param {object|null} existingProjectConfig 168 | * @return {{extraNodeModules: object}} 169 | */ 170 | function generateRootNodeModulesResolverConfig( 171 | projectRoot, 172 | existingProjectConfig = null, 173 | ) { 174 | // If the existing config has an `extraNodeModules` config option already set, that will get priority in resolution 175 | const extraNodeModules = 176 | existingProjectConfig && 177 | existingProjectConfig.resolver && 178 | existingProjectConfig.resolver.extraNodeModules 179 | ? existingProjectConfig.resolver.extraNodeModules 180 | : {}; 181 | 182 | return { 183 | extraNodeModules: new Proxy(extraNodeModules, { 184 | get: (target, name) => { 185 | if (name === util.inspect.custom) { 186 | return () => { 187 | return ( 188 | chalk.cyan('RootNodeModulesProxy<') + 189 | util.inspect(extraNodeModules) + 190 | chalk.cyan('>') 191 | ); 192 | }; 193 | } 194 | return typeof name === 'symbol' || has(name, target) 195 | ? target[name] 196 | : path.join(projectRoot, `node_modules/${name}`); 197 | }, 198 | }), 199 | }; 200 | } 201 | 202 | /** 203 | * Generate a list of watchFolders based on linked dependencies found, additional watch folders passed in as an option, 204 | * and addition watch folders detected in the existing config. 205 | * 206 | * @param {string[]} resolvedDevPaths A list of resolved, real, absolute paths to the dependencies that are linked 207 | * in node_modules. 208 | * @param {string[]} resolvedAdditionalWatchFolders A list of additional directories to watch (already resolved to real 209 | * absolute paths). 210 | * @param {{watchFolders: string[]}|null} existingProjectConfig 211 | * @return {string[]} 212 | */ 213 | function generateLinkedDependenciesWatchFolders( 214 | resolvedDevPaths = [], 215 | resolvedAdditionalWatchFolders = [], 216 | existingProjectConfig = null, 217 | ) { 218 | return [ 219 | ...resolvedDevPaths, 220 | ...resolvedAdditionalWatchFolders, 221 | ...((existingProjectConfig && existingProjectConfig.watchFolders) || 222 | []), 223 | ]; 224 | } 225 | 226 | /** 227 | * Warn the developer about the presence of symlinked dependencies. 228 | * 229 | * @param {string[]} resolvedDevPaths A list of resolved, real, absolute paths to the dependencies that are linked 230 | * in node_modules. 231 | * @param {string[]} blacklistLinkedModules 232 | */ 233 | function warnDeveloper(resolvedDevPaths = [], blacklistLinkedModules = []) { 234 | console.warn( 235 | chalk.yellow( 236 | 'Warning: you have symlinked dependencies in node_modules!\n', 237 | ), 238 | ); 239 | console.log( 240 | 'The following directories are symlink destinations of one or more node_modules \n' + 241 | '(i.e. `yarn link` or `npm link` was used to link in a dependency locally). Metro \n' + 242 | "bundler doesn't support symlinks so instead we'll manually watch the symlink \n" + 243 | 'destination. Note that if you get errors about name collisions, you need to inform \n' + 244 | '`@carimus/metro-symlinked-deps` of the colliding module(s) in metro.config.js via the \n' + 245 | '`blacklistLinkedModules` option passed to `applyConfigForLinkedDependencies`.', 246 | ); 247 | console.log('\n- %s\n', resolvedDevPaths.join('\n- ')); 248 | 249 | if (blacklistLinkedModules.length > 0) { 250 | console.log( 251 | 'Colliding modules that are blacklisted if they show up in the symlinked dependencies:', 252 | ); 253 | console.log('\n- %s\n', blacklistLinkedModules.join('\n- ')); 254 | } 255 | } 256 | 257 | /** 258 | * Transform a metro configuration object to allow it to support symlinked node_modules. 259 | * 260 | * @param {object=} projectConfig 261 | * @param {string|null=} projectRoot 262 | * @param {string[]} blacklistLinkedModules 263 | * @param {string[]} blacklistDirectories 264 | * @param {boolean=} resolveBlacklistDirectoriesSymlinks 265 | * @param {string[]=} additionalWatchFolders 266 | * @param {boolean=} resolveAdditionalWatchFoldersSymlinks 267 | * @param {boolean=} resolveNodeModulesAtRoot 268 | * @param {boolean=} silent 269 | * @param {boolean=} debug 270 | * @return {object} 271 | */ 272 | function applyConfigForLinkedDependencies( 273 | projectConfig = {}, 274 | { 275 | projectRoot = null, 276 | blacklistLinkedModules = [], 277 | blacklistDirectories = [], 278 | resolveBlacklistDirectoriesSymlinks = true, 279 | additionalWatchFolders = [], 280 | resolveAdditionalWatchFoldersSymlinks = true, 281 | resolveNodeModulesAtRoot = false, 282 | silent = false, 283 | debug = false, 284 | } = {}, 285 | ) { 286 | const realProjectRoot = path.resolve(projectRoot || inferProjectRoot()); 287 | if (!projectRoot && !silent) { 288 | console.warn( 289 | chalk.yellow( 290 | 'Warning: `applyConfigForLinkedDependencies` is being called without explicitly \n' + 291 | 'specifying `projectRoot`. ', 292 | ) + `It has been inferred as:\n\n${realProjectRoot}\n`, 293 | ); 294 | } 295 | 296 | // If the developer provided a blacklistRE in their project config, abort early. 297 | if ( 298 | projectConfig && 299 | projectConfig.resolver && 300 | projectConfig.resolver.blacklistRE 301 | ) { 302 | throw new Error( 303 | 'Refusing to override project-config-specified resolver.blacklistRE config value. ' + 304 | 'Use the `resolveDevPaths`, `generateLinkedDependenciesWatchFolders` and ' + 305 | '`generateLinkedDependenciesResolverConfig` functions directly instead of ' + 306 | '`applyConfigForLinkedDependencies` OR remove your specified resolver.blacklistRE value ' + 307 | "since we can't intelligently merge regular expressions.", 308 | ); 309 | } 310 | 311 | // Resolve all of the linked dependencies and only continue to modify config if there are any. 312 | const resolvedDevPaths = resolveDevPaths(realProjectRoot); 313 | 314 | if (resolvedDevPaths.length > 0 && !silent) { 315 | // Warn the user about the fact that the workaround is in effect. 316 | warnDeveloper(resolvedDevPaths, blacklistLinkedModules); 317 | } 318 | 319 | // Generate the metro config based on the options passed in 320 | const mergedConfig = mergeConfig(projectConfig, { 321 | resolver: { 322 | ...generateLinkedDependenciesResolverConfig( 323 | resolvedDevPaths, 324 | blacklistLinkedModules, 325 | resolvePaths( 326 | realProjectRoot, 327 | blacklistDirectories, 328 | resolveBlacklistDirectoriesSymlinks, 329 | ), 330 | ), 331 | ...(resolveNodeModulesAtRoot 332 | ? generateRootNodeModulesResolverConfig( 333 | realProjectRoot, 334 | projectConfig, 335 | ) 336 | : {}), 337 | }, 338 | watchFolders: generateLinkedDependenciesWatchFolders( 339 | resolvedDevPaths, 340 | resolvePaths( 341 | realProjectRoot, 342 | additionalWatchFolders, 343 | resolveAdditionalWatchFoldersSymlinks, 344 | ), 345 | projectConfig, 346 | ), 347 | }); 348 | 349 | if (debug) { 350 | console.log('Final metro configuration:\n\n%O\n', mergedConfig); 351 | } 352 | 353 | return mergedConfig; 354 | } 355 | 356 | module.exports = { 357 | inferProjectRoot, 358 | resolveDevPaths, 359 | generateBlacklistGroupForLinkedModules, 360 | generateLinkedDependenciesResolverConfig, 361 | generateLinkedDependenciesWatchFolders, 362 | warnDeveloper, 363 | applyConfigForLinkedDependencies, 364 | }; 365 | --------------------------------------------------------------------------------