nestjs/typescript-starter

Absolute import paths cannot be used in production

dislick opened this issue ยท 18 comments

Issue

Absolute import paths like import { foo } from 'src/utils/foo' work great with ts-node, but fail when running npm run start:prod.

Error

internal/modules/cjs/loader.js:583
    throw err;
    ^

Error: Cannot find module 'src/utils/foo'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:581:15)
    at Function.Module._resolveFilename (/Users/patrick/r/typescript-starter/node_modules/tsconfig-paths/lib/register.js:75:40)
    at Function.Module._load (internal/modules/cjs/loader.js:507:25)
    at Module.require (internal/modules/cjs/loader.js:637:17)
    at require (internal/modules/cjs/helpers.js:22:18)
    at Object.<anonymous> (/Users/patrick/r/typescript-starter/dist/main.js:13:15)
    at Module._compile (internal/modules/cjs/loader.js:689:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
    at Module.load (internal/modules/cjs/loader.js:599:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:538:12)

Here is a fork of typescript-starter with minimal changes to reproduce the issue. Compare changes.

The start:prod command is already changed to include the tsconfig-paths/register module, from

{
  "start:prod": "node dist/main.js"
}

to

{
  "start:prod": "node -r tsconfig-paths/register dist/main.js"
}

unfortunately without any effect.

Is this actually a supported way of running NestJS?

If it is, in addition to production it also does not appear to work in tests.

If it isn't supported we should remove tsconfig-paths from the starter repo. I was not even 100% aware I was using such a feature because of auto-imports in VSCode.

Also remove baseUrl from the tsconfig. I found this issue because I was going to open an issue asking for baseUrl to be removed because it causes vscode to make src/ imports. Which apparently is an intended feature of baseUrl. But not supported by the jest config to my knowledge (I'm pretty certain that import failures in jest for src/ imports is how I found they were being inserted).

You should never use such absolute imports src/utils/foo in your app because eventually, your code will very likely end up in a different directory (for example, dist). However, baseUrl is required in order to enable tsconfig paths which basic setup is shipped together with the starter project. Nonetheless, we don't force anybody to use them, even though it's a recommended way (it's always up to you).

However, baseUrl is required in order to enable tsconfig paths which basic setup is shipped together with the starter project. Nonetheless, we don't force anybody to use them, even though it's a recommended way (it's always up to you).

Is there any way tsconfig paths can be enabled without baseUrl? Or can we at least warn new users of the side effects.

Because baseUrl explicitly enables those src/ absolute imports. And as a result development tools output those absolute paths because tsconfig has told them you want them to be output.

@dantman Unfortunately, no. I have been struggling with the same issue as well and this is actually unbearable in the long run. I hope that IDEs will provide better integration with TS options soon.

@kamilmysliwiec Since you're having this issue to (in vscode I presume), could you try setting "javascript.preferences.importModuleSpecifier": "relative" and see if it does anything.

Thanks @dantman. However, I believe that it will disable typescript-paths feature which, on the other hand, are very useful.

@kamilmysliwiec Can you confirm if that is the case, I don't have any typescript-paths to test. If it is then I'll try to get the other bug reopened.

try create a index.js in root path

require("ts-node/register"); require("./src/main");

then, node -r tsconfig-paths/register index.js

The problem is that we don't have src directory in the dist after compilation.

This is the reason why imports like import ... from 'src/...' don't work in case compiling via tsc(when you making a build) or tsc-watch (when you running the app in dev mode).

There are several solutions:

  1. Add rootDir to your tsconfig.json to avoid omitting src directory during compilation.
    1.1. tsconfig.json:

    {
      "compilerOptions": {
        "baseUrl": "./",
      }
    }

    1.2 Add src to scripts in the package.json:

    "start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/src/main.js\"",

    Notice: imports like require('../ormconfig.json') from the root of project will not work. It looks like not a problem because these imports is a bad practice because in this case dist loses independence.

  2. Provide mapping without src in dist natively.
    2.1. Add NODE_PATH=dist prefix to your scripts in package.json.

    "start": "ts-node -r tsconfig-paths/register src/main.ts",
    "start:dev": "NODE_PATH=dist tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"",
    "start:prod": "NODE_PATH=dist node dist/main.js",

    Notice: don't add NODE_PATH to ts-node ... commands.
    2.2. Replace "baseUrl": "./", with "baseUrl": "./src", in the your tsconfig.json
    2.3. When you import a file from the root omit src/.
    Example: import { ... } from 'utils/foo'; instead of src/utils/foo.

  3. Provide mapping without src in dist using module-alias package.
    3.1. Install the package. npm i --save module-alias or yarn add module-alias.
    3.2. Add next code to the end of package.json

     "_moduleAliases": {
       "@app": "./dist"
    }
    

    3.3. Add paths to compileOptions in the tsconfig.json

      "paths": {
        "@app/*": ["./src/*"]
      },
    

    3.4. The package will break execution via ts-node. Therefore we need to udpate start command in the package.json

    "start": "IS_TS_NODE=true ts-node -r tsconfig-paths/register src/main.ts",
    

    3.5. Add next code to the top of src/main.ts file:

    if (!process.env.IS_TS_NODE) {
      // tslint:disable-next-line:no-var-requires
      require('module-alias/register');
    }
    

    3.6. Write your imports like import { ... } from '@app/utils/foo'; instead of src/utils/foo.

    Notice: You can define multiple aliases ;-) For example, @utils for src/utils

PS: I prefer the third solution.

Warning: Previously proposed solution is a bad solution!

try create a index.js in root path
require("ts-node/register"); require("./src/main");
then, node -r tsconfig-paths/register index.js

Because this is the same to ts-node -r tsconfig-paths/register src/main.ts, but via a hack.
In this case, we use ts-node to compile TS to JS on the fly. This is good for development reasons, however fatal for production because of broken performance.

Notes:
tsconfig-paths issues:

@korniychuk Thank you so much! Third solution is awesome.

The problem is that we don't have src directory in the dist after compilation.
...
3. Provide mapping without src in dist using module-alias package.
3.1. Install the package. npm i --save module-alias or yarn add module-alias.
3.2. Add next code to the end of package.json

 "_moduleAliases": {
   "@app": "./dist"
}

3.3. Add paths to compileOptions in the tsconfig.json

  "paths": {
    "@app/*": ["./src/*"]
  },

3.4. The package will break execution via ts-node. Therefore we need to udpate start command in the package.json

"start": "IS_TS_NODE=true ts-node -r tsconfig-paths/register src/main.ts",

3.5. Add next code to the top of src/main.ts file:

if (!process.env.IS_TS_NODE) {
  // tslint:disable-next-line:no-var-requires
  require('module-alias/register');
}

3.6. Write your imports like import { ... } from '@app/utils/foo'; instead of src/utils/foo.
Notice: You can define multiple aliases ;-) For example, @utils for src/utils

PS: I prefer the third solution.

The third solution can be improved.
We can:

  • avoid tsconfig-paths/register dependency
  • avoid aliases duplication in 3 places (tsconfig.json, jest config, module-alias config).
  • avoid IS_TS_NODE variable

To do this we need to write a simple script for jest and module-alias to load paths directly from tsconfig.json.

The full solution you can find in this fork of the original starter:
https://github.com/korniychuk/nestjs-starter

I've finally got it working without having to specify multiple aliases, and it works in production, here are the steps for my future self:

  1. Set the following parameters to tsconfig.json
{
  "compilerOptions": {
    //...other
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/*"]
    },
    "esModuleInterop": true
  }
}
  1. Install module-alias:
npm i module-alias
npm i -D @types/module-alias
  1. Create the file aliases.ts on ./src/config/aliases.ts with this code:
import moduleAlias from 'module-alias';
import path from 'path';

const rootPath = path.resolve(__dirname, '..', '..', 'dist');
moduleAlias.addAliases({
  '@src': rootPath,
});
  1. Import the newly created file on ./src/main.ts, note that this has to be the FIRST import:
import './config/aliases';
import { NestFactory } from '@nestjs/core';
// etc
  1. For the jest config (untested):
/* eslint-disable */
const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tsconfig');

module.exports = {
  preset: 'ts-jest',
  rootDir: '.',
  testEnvironment: 'node',
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
    prefix: '<rootDir>/',
  }),
};

Done! Basically the idea here is that with module-alias we can change the import url on runtime, so we can effectively resolve for a different path from dev mode to prod mode.

Now we can import with absolute paths, for example with @src/auth/auth.repository.ts

@microcipcip Thanks! You just saved my ass XD.

@kamilmysliwiec Since you're having this issue to (in vscode I presume), could you try setting "javascript.preferences.importModuleSpecifier": "relative" and see if it does anything.

This one worked for me perfectly.

@microcipcip
Thanks, this finally worked. But I still don't get it why some Nestjs projects work fine without any external library installed such as module_alias, and some Nestjs projects fail to resolve alias path.

@microcipcip Thanks, this finally worked. But I still don't get it why some Nestjs projects work fine without any external library installed such as module_alias, and some Nestjs projects fail to resolve alias path.

If for some projects works it may be that they have solved it with a slightly different configuration like point 2.0 of this answer.