enhanced-resolve icon indicating copy to clipboard operation
enhanced-resolve copied to clipboard

`extensionAlias` is not applied to module exports

Open remcohaszing opened this issue 3 years ago • 6 comments

Let’s say we have a TypeScript mono repo with one package that depends on another. The actual TypeScript configuration is left out, because it’s not needed for this example.

webpack.config.js:

/** @type {import('webpack').Configuration} */
export default {
  entry: './packages/foo/index.ts',
  mode: 'development',
  resolve: {
    extensions: ['.js', '.ts'],
    extensionAlias: {
      '.js': ['.js', '.ts']
    }
  }
}

package.json:

{
  "type": "module",
  "workspaces": [
    "packages/*"
  ],
  "dependencies": {
    "webpack": "^5.0.0",
    "webpack-cli": "^4.0.0"
  }
}

foo/packages/package.json:

{
  "name": "foo",
  "type": "module",
  "exports": "./index.js"
}

foo/packages/index.ts:

import { bar } from 'bar'

console.log(bar)

bar/packages/package.json:

{
  "name": "bar",
  "type": "module",
  "exports": "./index.js"
}

bar/packages/index.ts:

export const bar = 'bar'

Now if we build using Webpack, it throws the following error:

~/Projects/bug$ webpack
asset main.js 2.34 KiB [emitted] (name: main)
runtime modules 274 bytes 1 module
./packages/foo/index.ts 44 bytes [built] [code generated]

ERROR in ./packages/foo/index.ts 1:0-25
Module not found: Error: Can't resolve 'bar' in '/home/remco/Projects/bug/packages/foo'
resolve 'bar' in '/home/remco/Projects/bug/packages/foo'
  Parsed request is a module
  using description file: /home/remco/Projects/bug/packages/foo/package.json (relative path: .)
    Field 'browser' doesn't contain a valid alias configuration
    resolve as module
      /home/remco/Projects/bug/packages/foo/node_modules doesn't exist or is not a directory
      /home/remco/Projects/bug/packages/node_modules doesn't exist or is not a directory
      looking for modules in /home/remco/Projects/bug/node_modules
        single file module
          using description file: /home/remco/Projects/bug/package.json (relative path: ./node_modules/bar)
            no extension
              Field 'browser' doesn't contain a valid alias configuration
              /home/remco/Projects/bug/node_modules/bar is not a file
            .js
              Field 'browser' doesn't contain a valid alias configuration
              /home/remco/Projects/bug/node_modules/bar.js doesn't exist
            .ts
              Field 'browser' doesn't contain a valid alias configuration
              /home/remco/Projects/bug/node_modules/bar.ts doesn't exist
        existing directory /home/remco/Projects/bug/node_modules/bar
          using description file: /home/remco/Projects/bug/node_modules/bar/package.json (relative path: .)
            using exports field: ./index.js
              using description file: /home/remco/Projects/bug/node_modules/bar/package.json (relative path: ./index.js)
                no extension
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist
                .js
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js.js doesn't exist
                .ts
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js.ts doesn't exist
                as directory
                  /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist
      /home/remco/Projects/node_modules doesn't exist or is not a directory
      /home/remco/node_modules doesn't exist or is not a directory
      /home/node_modules doesn't exist or is not a directory
      /node_modules doesn't exist or is not a directory

This shows that extensionAlias isn’t applied when resolving import 'bar' from the foo project. This can be worked around by removing the exports field from package.json, but that will cause Node.js to show deprecation warnings if the package would be published and consumed.

remcohaszing avatar Sep 13 '22 09:09 remcohaszing

Hello, sorry for delay, currently it is expected, because exports should work as written (i.e. if you requested file.js we should return file.js and if you requested file.ts we should return index.ts).

But this case is intresting (especially since module request can only be done for Node.js), to be honest I don't have strong opinion here, on the one hand, violation of the specification for this would be a completely wrong decision, but on the other side I see your problem and what you are trying to solve (monorepo and typescript).

Based on the logic of modularity and forget about mono-repositories, that is, each package is in its own repository and requires publication (i.e. npm publish) in the registry before using it, then you would anyway need to publish js files (i.e. compile/transpile them) and have the exports with js files (otherwise developer without typescript can't consume your library). Yes, we have the extensionAlias option, but they are required a little for another problem and was not design to change exports extensions.

Also If we allow to do it, some developers can start to rely on this and create a situation when package can't be used without webpack, that is really bad.

So I think we're doing the right thing here.

I see these solution (and consider them valid as for any bundler as for Node.js itself)

  1. Using import foo from "package/index.ts"
  2. The subpath solution:
{
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./ts": "./index.ts"
  }
}

So you can use import foo from "foo/ts";, but other developers can stoll consume your library using import foo from "foo";

  1. The condition exports https://nodejs.org/api/packages.html#conditional-exports - add typescript section when set them for webpack resolver
  2. Alternative - create an own plugin for resolver (it will be not hard)

alexander-akait avatar Sep 15 '22 23:09 alexander-akait

I see what you mean, but I don’t consider this to be a violation of the spec, but parity with the rest of the ecosytem.

As can be seen in the output I posted, Webpack already tries to apply the extensions defined in resolve.extensions to the resolved import. This would be the same type of violation.

using description file: /home/remco/Projects/bug/node_modules/bar/package.json (relative path: ./index.js)
  no extension
    Field 'browser' doesn't contain a valid alias configuration
    /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist
  .js
    Field 'browser' doesn't contain a valid alias configuration
    /home/remco/Projects/bug/node_modules/bar/index.js.js doesn't exist
  .ts
    Field 'browser' doesn't contain a valid alias configuration
    /home/remco/Projects/bug/node_modules/bar/index.js.ts doesn't exist
  as directory
    /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist

TypeScript’s node16 module resolution, understands that the referenced .js file referenced in package exports field should be resolved to the .ts file instead. Also ts-node understands this.

A very similar issue has been acknowledged in esbuild-kit/tsx#59.

I really appreciate you’re thinking along, but this workaround won’t always work:

{
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./ts": "./index.ts"
  }
}

Let’s say we have package a, which is published to npm, package b, which depends on a and is also published to npm, and package c, which is private and built using Webpack. Now package b would need to import a in case it’s published to npm, and a/ts when it’s consumed by c.

remcohaszing avatar Sep 16 '22 12:09 remcohaszing

I think the problem is still unresolved (in esbuild and for us too) because it creates a situation where users start writing:

{
  "type": "module",
  "exports": {
    ".": "./index.ts"
  }
}

Because they will expect mapping ts(tsx) to js, but it is fully wrong...

alexander-akait avatar Sep 16 '22 12:09 alexander-akait

hm, Intresting, if you have:

  "exports": {
    ".": "./index.js",
     "./main.js": "./main.js"
  }

and use import foo from "foo/main.js";, we apply extensionAlias logic, but output an error here, because it was not exports - i.e. Package path ./main.ts is not exported from package and we need to output this and it is valid - you cannot import anything that is not in the exports field.

So you need:

  "exports": {
    ".": "./index.js",
    "./main.ts": "./main.js",
    "./main.js": "./main.ts"
  }

And I think you see the problem here.

Also Looking at code and the Node.js logic (and not only), I still think you should use condition exports here, otherwise you will get something like above - we can't resolve file if don't have it in the exports file.

@vankop @sokra What do you think?

alexander-akait avatar Sep 16 '22 13:09 alexander-akait

I fully agree with @alexander-akait idea => exports should work as written.

btw this seems wrong to me:

using exports field: ./index.js
              using description file: /home/remco/Projects/bug/node_modules/bar/package.json (relative path: ./index.js)
                no extension
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist
                .js
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js.js doesn't exist
                .ts
                  Field 'browser' doesn't contain a valid alias configuration
                  /home/remco/Projects/bug/node_modules/bar/index.js.ts doesn't exist
                as directory
                  /home/remco/Projects/bug/node_modules/bar/index.js doesn't exist

we should not apply extensions..

vankop avatar Oct 03 '22 18:10 vankop

@vankop Yeah we should decide here - should we apply extensionAlias/extensions here or not

@sokra Need you advice

alexander-akait avatar Oct 06 '22 15:10 alexander-akait

For anyone who's also running into this issue, I've fixed this using alternatives as described in the webpacks docs(https://webpack.js.org/guides/package-exports/#alternatives):

  "exports": {
    ".": ["./src/index.js", "./src/index.ts"],
    "./*": ["./src/*/index.js", "./src/*.js", "./src/*/index.ts", "./src/*.ts"]
  },

I couldn't find if this is according to the esm spec, but it also works with tsc.

wesselvdv avatar Dec 17 '22 09:12 wesselvdv

Just encountered this issue with a slightly different setup. I have the following in my exports:

	"exports": {
		".": {
			"import": "./dist/index.js"
		},
		"./runtime.js": {
			"import": "./dist/runtime.mjs"
		}
	},

And I'm doing import * as __i18n from "@nmid/i18n/runtime.js"; which results in:

Package path ./runtime.ts is not exported from package.

I'd argue this is definitely wrong, and I have no idea why it's trying to import a typescript file here. runtime.ts does not exist in the package folder even. It's under src.

Alxandr avatar Apr 27 '23 08:04 Alxandr

Please provide example of the problem

alexander-akait avatar Apr 27 '23 11:04 alexander-akait

I made a minimal reproducible one of the issue here. https://github.com/raviqqe/til/tree/main/webpack-exports-extension-alias

raviqqe avatar Apr 27 '23 11:04 raviqqe

You have:

 "exports": {
    "./*.js": {
      "types": "./dist/*.d.ts",
      "default": "./dist/*.js"
    }
  },

And what you expected? Typescript code? @raviqqe/hidash doesn't have typescript code, look at package structure, I see only string.test.d.ts and it is export {};, it is not code.

Also types is special thing for typescript, you have two thing wrong:

  1. "./*.js" - you says - you allow to load only js files, change it to "./*"
  2. If you want to import types you need to use @import something from "@raviqqe/hidash/types/string" or for regular export @import something from "@raviqqe/hidash/string"

If you want to use .js at the end you need to create two entrypint in export - for regular js and types, it is not my rules, it is how exports works

alexander-akait avatar Apr 27 '23 12:04 alexander-akait

"./.js" - you says - you allow to load only js files, change it to "./"

It's just to load JS files. So it doesn't matter.

If you want to import types you need to use @import something from "@raviqqe/hidash/types/string" or for regular export @import something from "@raviqqe/hidash/string"

The types field is for TypeScript's type system to load types of the JS files automatically.

Both usage is described in the Node.js's documentation (https://nodejs.org/api/packages.html#subpath-exports). I'm not sure if I'm using it correctly but at least it works with the latest TypeSript's module resolution strategy of node16 and nodenext.

@alexander-akait Are you really sure that the usage is wrong? It's literally written in the official documentation.

This is also a good read to understand how TypeScript uses exports fields. https://github.com/microsoft/TypeScript/issues/33079

raviqqe avatar Apr 27 '23 13:04 raviqqe

@alexander-akait Are you really sure that the usage is wrong? It's literally written in the official documentation.

Where? I mean you can use */*.js, but if you want to export typescript code (not just types) and mapping it, you can't, you should avoid using extensions so extensionAlias can check all possible extensions

The types conditional field doesn't used by bundlers, it is for types checking tools, webpack is not type checking tool, so I really can't undestand what you try to achive and how it related to extensionAlias

You can add this field to https://webpack.js.org/configuration/resolve/#resolveconditionnames and webpack will resolve files from the types field, but again, what is the point to do it

alexander-akait avatar Apr 27 '23 14:04 alexander-akait

Where?

Here.

  • https://nodejs.org/api/packages.html#subpath-exports
  • https://github.com/microsoft/TypeScript/issues/33079

I think there is a fundamental misunderstanding here about how TypeScript's new module resolution (node16 and nodenext) work with ES modules. In the cases of @Alxandr and mine, we are just trying to resolve JS files from the external packages with exports fields with Webpack as you suggested. The reason we need to use the extensionAlias option is that the TypeScript compiler doesn't transpile import paths from .ts to .js for fully qualified imports in ESM, which is how the TS team decided to do (https://github.com/microsoft/TypeScript/issues/35589, https://github.com/microsoft/TypeScript/issues/40878.)

raviqqe avatar Apr 27 '23 23:04 raviqqe

I think there is a fundamental misunderstanding here about how TypeScript's new module resolution (node16 and nodenext) work with ES modules.

Is it? I implemented this logic a long time ago https://github.com/TypeStrong/ts-loader/issues/1383#issuecomment-968075478, when typescript realses it, it seems to me that you do not want to listen to me

Again - you have a typescript file and want to use extensionAlias, but file IN conditinalName field and has nothing to do with extensionAlias option, so if you want to support types, you need to set https://webpack.js.org/configuration/resolve/#resolveconditionnames, by default webpack for javascript and know nothing about typescript/coffeescript/flow/etc, so loader like ts-loader should create an own resolver and provide additional fields in conditinalNames, example sass-loader - https://github.com/webpack-contrib/sass-loader/blob/master/src/utils.js#L533 add sass and style fields, webpack knows nothing about how CSS works and how to resolve it.

alexander-akait avatar Apr 28 '23 00:04 alexander-akait

Yes, you do. 😭

so if you want to support types, you need to set https://webpack.js.org/configuration/resolve/#resolveconditionnames,

No, I'm not talking about supporting the types conditional fields... I just brought up the use of extensionAlias for TypeScript because it's one of the primary use cases. The bug actually has nothing to do with TypeScript. As I mentioned before, we are not trying to resolve TypeScript files with the types condition names in Webpack. The statement I'm making is nothing to do with the condition names.

I made my reproducible example TS-free (but with Lisp :) to help your understanding of the bug.

  • https://github.com/raviqqe/til/tree/main/webpack-exports-extension-alias

Now, the error message looks like this with extensionAlias of ".js": [".lisp", ".js"]:

> npm run build

> [email protected] build
> webpack

assets by status 122 bytes [cached] 1 asset
./src/main.js 43 bytes [built] [code generated]

ERROR in ./src/main.js 1:0-42
Module not found: Error: Package path ./string.lisp is not exported from package /Users/raviqqe/src/github.com/raviqqe/til/webpack-exports-extension-alias/node_modules/@raviqqe/hidash (see exports field in /Users/raviqqe/src/github.com/raviqqe/til/webpack-exports-extension-alias/node_modules/@raviqqe/hidash/package.json)

webpack 5.80.0 compiled with 1 error in 86 ms

raviqqe avatar Apr 28 '23 01:04 raviqqe

Now I see, yeah, it is a bug, I thought that the main idea is to import it, but here it’s the other way around, the idea is not to try to import ts file

alexander-akait avatar Apr 28 '23 02:04 alexander-akait

The fix is easy, but we need to do deep test with it (I will do soon, hope this week), because ignoring errors can be a problem in some cases when you have extensionAlias

alexander-akait avatar Apr 28 '23 02:04 alexander-akait

In case someone else arrives here with this issue before it's solved, and need a work-around, I ended up just using a non-aliased file-extension in my exports:

	"exports": {
		".": {
			"import": "./dist/index.js"
		},
		"./runtime.mjs": { // <- mjs is not aliased in my project.
			"import": "./dist/runtime.mjs" // <- the extension here does not need to be the same as the line above (even though it is in this case)
		}
	},

[Edit]:
I would also generally recomend against using * in exports, but that's an aside not really related to this issue. Just saw a lot of it posted here.

Alxandr avatar Apr 28 '23 07:04 Alxandr

@Alxandr * exports were fixed recently, now we use the same logic as in Node.js source code, exactly the same code, so using * is safe to use

alexander-akait avatar May 07 '23 15:05 alexander-akait

@alexander-akait it was not a "don't use it cause it's unsafe". It was just a general in my honest opinion, it's a bad idea to use * exports, cause you make the entire surface of your package public API.

Alxandr avatar May 08 '23 12:05 Alxandr