Skip to content

Commit

Permalink
fix(metro-resolver-symlinks): add lib -> src remapper util (#905)
Browse files Browse the repository at this point in the history
- Refactored resolvers to conform to a single interface
- Added a utility for remapping import paths (akin to babel-plugin-import-path-remapper)
- Updated README
  • Loading branch information
tido64 committed Dec 2, 2021
1 parent 0d1b98f commit 1a2cf67
Show file tree
Hide file tree
Showing 30 changed files with 510 additions and 126 deletions.
5 changes: 5 additions & 0 deletions .changeset/perfect-birds-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rnx-kit/tools-react-native": minor
---

Added functions for retrieving platform extensions
7 changes: 7 additions & 0 deletions .changeset/wise-dingos-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rnx-kit/metro-resolver-symlinks": patch
---

- Refactored resolvers to conform to a single interface
- Added a utility for remapping import paths (akin to babel-plugin-import-path-remapper)
- Updated README
51 changes: 50 additions & 1 deletion packages/metro-resolver-symlinks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,21 @@
symlinks. This is especially useful in monorepos, or repos using package
managers that make heavy use of symlinks (such as pnpm).

## Installation

```sh
yarn add @rnx-kit/metro-resolver-symlinks --dev
```

or if you're using npm

```sh
npm add --save-dev @rnx-kit/metro-resolver-symlinks
```

## Usage

Import and set the resolver to `resolver.resolveRequest` in your
Import and assign the resolver to `resolver.resolveRequest` in your
`metro.config.js`:

```js
Expand All @@ -23,3 +35,40 @@ module.exports = makeMetroConfig({
},
});
```

## Options

| Option | Type | Description |
| :---------- | :----------------------------- | :-------------------------------------------------- |
| remapModule | `(moduleId: string) => string` | A custom function for remapping additional modules. |

### `remapModule`

`remapModule` allows additional remapping of modules. For instance, there is a
`remapImportPath` utility that remaps requests of `lib/**/*.js` to
`src/**/*.ts`. This is useful for packages that don't correctly export
everything in their main JS file.

```diff
const { makeMetroConfig } = require("@rnx-kit/metro-config");
const MetroSymlinksResolver = require("@rnx-kit/metro-resolver-symlinks");

module.exports = makeMetroConfig({
projectRoot: __dirname,
resolver: {
resolveRequest: MetroSymlinksResolver({
+ remapModule: MetroSymlinksResolver.remapImportPath({
+ test: (moduleId) => moduleId.startsWith("@rnx-kit/"),
+ extensions: [".ts", ".tsx"], # optional
+ mainFields: ["module", "main"], # optional
+ }),
}),
},
});
```

> **Sidenote:** When Metro releases a version with the ability to set a
> [custom resolver for Haste requests](https://github.com/facebook/metro/commit/96fb6e904e1660b37f4d1f5353ca1e5477c4afbf),
> this way of remapping modules is preferable over
> `@rnx-kit/babel-plugin-import-path-remapper`. The Babel plugin mutates the AST
> and requires a second pass.
8 changes: 5 additions & 3 deletions packages/metro-resolver-symlinks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"homepage": "https://github.com/microsoft/rnx-kit/tree/main/packages/metro-resolver-symlinks#readme",
"license": "MIT",
"files": [
"lib/*.d.ts",
"lib/*.js"
"lib/**/*.d.ts",
"lib/**/*.js"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand All @@ -22,7 +22,9 @@
"test": "rnx-kit-scripts test"
},
"dependencies": {
"@rnx-kit/tools-node": "^1.2.6"
"@rnx-kit/tools-node": "^1.2.6",
"@rnx-kit/tools-react-native": "^1.0.10",
"enhanced-resolve": "^5.8.3"
},
"devDependencies": {
"@rnx-kit/eslint-config": "*",
Expand Down
41 changes: 13 additions & 28 deletions packages/metro-resolver-symlinks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import { normalizePath } from "@rnx-kit/tools-node";
import type { ResolutionContext } from "metro-resolver";
import type { MetroResolver } from "./resolver";
import {
getMetroResolver,
remapReactNativeModule,
resolveModulePath,
} from "./resolver";

type Options = {
remapModule?: (
context: ResolutionContext,
moduleName: string,
platform: string
) => string;
};
import type { MetroResolver, Options } from "./types";
import { remapImportPath } from "./utils/remapImportPath";

function makeResolver({
remapModule = (_, moduleName, __) => moduleName,
}: Options = {}): MetroResolver {
const resolve = getMetroResolver();

// TODO: Read available platforms from `react-native config`.
const availablePlatforms = {
macos: "react-native-macos",
win32: "@office-iss/react-native-win32",
windows: "react-native-windows",
};
const resolvers = [remapModule, remapReactNativeModule, resolveModulePath];

return (context, moduleName, platform) => {
if (!platform) {
Expand All @@ -35,19 +21,16 @@ function makeResolver({
const backupResolveRequest = context.resolveRequest;
delete context.resolveRequest;

let modifiedModuleName = remapModule(context, moduleName, platform);
modifiedModuleName = remapReactNativeModule(
modifiedModuleName,
platform,
availablePlatforms
const modifiedModuleName = resolvers.reduce(
(modifiedName, remap) => remap(context, modifiedName, platform),
moduleName
);
modifiedModuleName = resolveModulePath(
modifiedModuleName,
context.originModulePath
);
modifiedModuleName = normalizePath(modifiedModuleName);

const resolution = resolve(context, modifiedModuleName, platform);
const resolution = resolve(
context,
normalizePath(modifiedModuleName),
platform
);

// Restoring `resolveRequest` must happen last
context.resolveRequest = backupResolveRequest;
Expand All @@ -56,4 +39,6 @@ function makeResolver({
};
}

makeResolver.remapImportPath = remapImportPath;

export = makeResolver;
31 changes: 16 additions & 15 deletions packages/metro-resolver-symlinks/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isFileModuleRef, parseModuleRef } from "@rnx-kit/tools-node";
import path from "path";

export type MetroResolver = typeof import("metro-resolver").resolve;
import { AVAILABLE_PLATFORMS } from "@rnx-kit/tools-react-native";
import * as path from "path";
import type { MetroResolver, ModuleResolver } from "./types";

function resolveFrom(moduleName: string, startDir: string): string {
return require.resolve(moduleName, { paths: [startDir] });
Expand All @@ -28,12 +28,12 @@ export function getMetroResolver(fromDir = process.cwd()): MetroResolver {
}
}

export function remapReactNativeModule(
moduleName: string,
platform: string,
platformImplementations: Record<string, string>
): string {
const platformImpl = platformImplementations[platform];
export const remapReactNativeModule: ModuleResolver = (
_context,
moduleName,
platform
) => {
const platformImpl = AVAILABLE_PLATFORMS[platform];
if (platformImpl) {
if (moduleName === "react-native") {
return platformImpl;
Expand All @@ -42,12 +42,13 @@ export function remapReactNativeModule(
}
}
return moduleName;
}
};

export function resolveModulePath(
moduleName: string,
originModulePath: string
): string {
export const resolveModulePath: ModuleResolver = (
{ originModulePath },
moduleName,
_platform
) => {
// Performance: Assume relative links are not going to hit symlinks
const ref = parseModuleRef(moduleName);
if (isFileModuleRef(ref)) {
Expand All @@ -63,4 +64,4 @@ export function resolveModulePath(
return relativePath.startsWith(".")
? relativePath
: `.${path.sep}${relativePath}`;
}
};
18 changes: 18 additions & 0 deletions packages/metro-resolver-symlinks/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ResolutionContext as MetroResolutionContext } from "metro-resolver";

export type MetroResolver = typeof import("metro-resolver").resolve;

export type ResolutionContext = Pick<
MetroResolutionContext,
"originModulePath"
>;

export type ModuleResolver = (
context: ResolutionContext,
moduleName: string,
platform: string
) => string;

export type Options = {
remapModule?: ModuleResolver;
};
136 changes: 136 additions & 0 deletions packages/metro-resolver-symlinks/src/utils/remapImportPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { PackageModuleRef } from "@rnx-kit/tools-node";
import { isFileModuleRef, parseModuleRef } from "@rnx-kit/tools-node";
import { expandPlatformExtensions } from "@rnx-kit/tools-react-native";
import * as fs from "fs";
import * as path from "path";
import type { ModuleResolver, ResolutionContext } from "../types";

type Resolver = (fromDir: string, moduleId: string) => string;

type ResolverOptions = {
extensions: string[];
mainFields: string[];
};

type Options = Partial<ResolverOptions> & {
test: (moduleName: string) => boolean;
};

const DEFAULT_OPTIONS = {
extensions: [".ts", ".tsx"],
mainFields: ["module", "main"],
};

export function remapLibToSrc(
{ originModulePath }: ResolutionContext,
ref: PackageModuleRef,
resolver: Resolver
): string | undefined {
const pattern = /^(.\/)?lib\b/;
const { name, scope, path: modulePath } = ref;
if (!modulePath || !pattern.test(modulePath)) {
return undefined;
}

const fromDir = originModulePath
? path.dirname(originModulePath)
: process.cwd();
const packageName = scope ? `${scope}/${name}` : name;
return resolver(
fromDir,
`${packageName}/${modulePath.replace(pattern, "src")}`
);
}

export function resolveModule(
fromDir: string,
moduleId: string,
resolver: Resolver,
{ mainFields }: ResolverOptions
): string {
const manifestPath = resolver(fromDir, moduleId + "/package.json");
const content = fs.readFileSync(manifestPath, { encoding: "utf-8" });
const manifest = JSON.parse(content);
for (const mainField of mainFields) {
const main = manifest[mainField];
if (main) {
return main;
}
}

throw new Error(
`A main field (e.g. ${mainFields.join(", ")}) is missing for '${moduleId}'`
);
}

export function resolveModulePath(
{ originModulePath }: ResolutionContext,
ref: PackageModuleRef,
resolver: Resolver,
options: ResolverOptions
): PackageModuleRef {
if (ref.path) {
return ref;
}

const fromDir = originModulePath
? path.dirname(originModulePath)
: process.cwd();
const { name, scope } = ref;
const packageName = scope ? `${scope}/${name}` : name;
const modulePath = resolveModule(fromDir, packageName, resolver, options);

return {
name,
scope,
path: modulePath.replace(/\.jsx?$/, ""),
};
}

export function remapImportPath(pluginOptions: Options): ModuleResolver {
if (!pluginOptions) {
throw new Error("A test function is required for this plugin");
}

const { test, ...options } = pluginOptions;
if (typeof test !== "function") {
throw new Error(
"Expected option `test` to be a function `(moduleId: string) => boolean`"
);
}

const resolverOptions = {
...DEFAULT_OPTIONS,
...options,
};

const getResolver = (() => {
const { extensions, mainFields } = resolverOptions;
let resolve: Resolver;
return (platform: string) => {
if (!resolve) {
resolve = require("enhanced-resolve").create.sync({
extensions: expandPlatformExtensions(platform, extensions),
mainFields,
});
}
return resolve;
};
})();

return (context, moduleId, platform) => {
const ref = parseModuleRef(moduleId);
if (isFileModuleRef(ref) || !test(moduleId)) {
return moduleId;
}

const resolve = getResolver(platform);
const resolvedRef = resolveModulePath(
context,
ref,
resolve,
resolverOptions
);
return remapLibToSrc(context, resolvedRef, resolve) || moduleId;
};
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1a2cf67

Please sign in to comment.