TriPSs / nx-extend

Nx Workspaces builders and tools
MIT License
162 stars 44 forks source link

Using NX libs not possible in Strapi project #49

Open stefanbinder opened 2 years ago

stefanbinder commented 2 years ago

Hi, thanks for the strapi extension!

Currently it is not possible to use nx-libs in the strapi-server. eg. I have a JS/ lib @my-nx-workspace/utils, then I can't require it like const utils = require('@my-nx-workspace/utils'); inside strapi.

Any ideas how to solve that mix of old require and new ES6 importing? Thanks

TriPSs commented 2 years ago

I don't have any active strapi project atm so i'm not quite sure, I do see that they are working on typescript support so it should then be a matter of updating that config with the aliases / extend the root one.

Maybe soon i will have a use case again for Strapi and will than also check the typescript implementation.

florianmrz commented 1 year ago

I was facing this exact issue and needed to get Strapi working in the monorepo with integration for nx libraries. This library is great and it was working wonderful until I needed to integrate nx libraries, which simply did not work for multiple reasons, mainly that Strapi heavily depends on its very specific directory structure that gets thrown overboard when also compiling libs together with the app.

I've decided to create scripts to get this to work with an nx setup. One is responsible for serving the project locally, one is to build it and one to run the built output in production. All of them are heavily inspired by this package, but have a few configuration changes to make this work.

More on the scripts below.


There are two key components to get Strapi to work with nx libraries:

1. Handle the new folder structure due to the compiled libraries

Before, the dist folder looked like this:

dist/
├─ src/ (server-side code)
├─ config/ (various config files)
├─ build/ (admin panel)

Now, since we include the libraries, this folder structure changes to:

dist/
├─ apps/
│  ├─ my-app/
│  │  ├─ src/ (server-side code)
│  │  ├─ config/ (various config files)
│  │  ├─ build/ (admin panel)
├─ libs/
│  ├─ my-lib/

This needs to be accounted for in various places, e.g. when resolving local plugins:

// config/plugins.ts

// ...
    'my-plugin': {
      enabled: true,
      resolve: `./${isRunningInServeMode ? '' : 'apps/my-app/'}src/plugins/my-plugin`,
    },
// ...

2. Use of tsconfig-paths

I'm making heavy use of the tsconfig-paths plugin in order to make the imports of the nx libraries map to the respective files. It will patch the require() calls to resolve the nx library imports to their actual files. This is needed as the compiled code of Strapi will still include imports such as require('@my-project/my-lib') that need to be resolved to the correct library.

For admin panel plugins to access nx libraries, we need to include the tsconfig-paths-webpack-plugin plugin in the admin webpack configuration.

// apps/my-app/src/admin/webpack.config.js

const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = (config, webpack) => {
  config.resolve.plugins = [
    new TsconfigPathsPlugin({
      configFile: 'tsconfig.base.json',
      baseUrl: process.env.NODE_ENV === 'production' ? 'dist/apps/my-app' : 'apps/my-app/dist',
      extensions: ['.ts', '.js'],
    }),
  ];
  return config;
};

⚠️Important note: Make sure that the path entries in your base TS config don't use a file extension such as .ts. Because the transpiled files will end in .js, those imports will fail!

Simply omit the extension, e.g.:

// tsconfig.base.json

"@my-project/shared/example": ["libs/shared/src/example.ts"] // fails
"@my-project/shared/example": ["libs/shared/src/example"]    // works

We also need to update the tsconfig files for both the admin panel as well as the server-side one. They need to extend our base tsconfig file that includes our paths options resolving to our libraries.

// apps/my-app/tsconfig.json

{
  // We need to extend our base tsconfig file
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    // Add all other compilerOptions from https://github.com/strapi/strapi/blob/main/packages/utils/typescript/tsconfigs/server.json
    // ...
  },
}
// apps/my-app/src/admin/tsconfig.json

{
  // We need to extend our base tsconfig file
  "extends": "../../../../tsconfig.base.json",
  "compilerOptions": {
    "module": "ES2020",
    // Add all other compilerOptions from https://github.com/strapi/strapi/blob/main/packages/utils/typescript/tsconfigs/admin.json
    // ...
  },
}

Scripts to serve, build and start (a production build)

To serve and build the project using the local script, I updated the project configuration:

// apps/my-app/project.json

// ...
"targets": {
  "serve": {
    "executor": "nx:run-commands",
    "options": {
      "command": "node apps/my-app/scripts/serve.js",
      "envFile": "apps/my-app/.env"
    }
  },
  "build": {
    "executor": "nx:run-commands",
    "options": {
      "command": "node apps/my-app/scripts/build.js"
    }
  }
}
// ...

Serve the Strapi project locally:

// apps/my-app/scripts/serve.js

const tsConfig = require('../../../tsconfig.base.json');
const tsConfigPaths = require('tsconfig-paths');
const strapi = require('@strapi/strapi');
const path = require('path');
const { buildAdmin } = require('@strapi/strapi/lib/commands/builders');
const tsUtils = require('@strapi/typescript-utils');

const appName = 'my-app';
const strapiRoot = path.join(__dirname, '..');
const distDirRoot = path.join(strapiRoot, 'dist');
const distDirApp = path.join(distDirRoot, 'apps', appName);

tsConfigPaths.register({
  baseUrl: distDirRoot,
  paths: tsConfig.compilerOptions.paths,
});

(async () => {
  await tsUtils.compile(strapiRoot, { watch: false });

  await buildAdmin({ forceBuild: true, buildDestDir: distDirApp, srcDir: strapiRoot });

  const app = strapi({ appDir: strapiRoot, distDir: distDirApp });
  app.start();
})();

Build it for production:

// apps/my-app/scripts/build.js

const tsConfig = require('../../../tsconfig.base.json');
const tsConfigPaths = require('tsconfig-paths');
const path = require('path');
const { buildAdmin } = require('@strapi/strapi/lib/commands/builders');
const tsUtils = require('@strapi/typescript-utils');

const appName = 'my-app';
const strapiRoot = path.join(__dirname, '..');
const distDirRoot = path.join(strapiRoot, '../../dist/apps', appName);
const distDirApp = path.join(distDirRoot, 'apps', appName);

tsConfigPaths.register({
  baseUrl: distDirRoot,
  paths: tsConfig.compilerOptions.paths,
});

(async () => {
  await tsUtils.compile(strapiRoot, {
    watch: false,
    configOptions: {
      options: {
        outDir: distDirRoot,
      },
    },
  });

  await buildAdmin({ forceBuild: true, buildDestDir: distDirApp, srcDir: strapiRoot });
})();

Run the built version in production:

// apps/my-app/scripts/start.js

const tsConfig = require('../tsconfig.base.json');
const tsConfigPaths = require('tsconfig-paths');
const strapi = require('@strapi/strapi');
const path = require('path');

const appName = 'my-app';
const strapiRoot = path.join(__dirname, '..');
const distDirRoot = strapiRoot;
const distDirApp = path.join(distDirRoot, 'apps', appName);

tsConfigPaths.register({
  baseUrl: distDirRoot,
  paths: tsConfig.compilerOptions.paths,
});

(async () => {
  const app = strapi({ appDir: strapiRoot, distDir: distDirApp });
  app.start();
})();

Running in production

A few things need to copied to the dist folder dist/apps/my-app (in my case, this is done in a Dockerfile):

Caveats:

I hope this insight into my experience with setting this up is helpful to somebody else facing the same or similar issues.

florianmrz commented 1 year ago

I decided to throw together an example project using the scripts mentioned above: florianmrz/nx-strapi-with-libraries-example

@TriPSs I hope this is helpful to maybe get this supported through this package. Even though it was quite a few things that needed to be changed in order to get this to work, it might be feasible to enable this behind an option flag?

TriPSs commented 1 year ago

@florianmrz first of all thanks for figuring all this shit out!

I already tried a bit to make it work with everything you mentioned, it then was able to build but the output become very weird (keeping the folder structure of the libs/apps) causing it not to start.

When I have some time again I will try to continue to implement/make something work with what you mentioned above so we can add proper support for it through this package.

icastillejogomez commented 1 year ago

I finally found the way to run strapi with the Nx libs but with the provided scripts there aren't any ways to edit the content type. Please help :(

icastillejogomez commented 1 year ago

I found the way. Adding autoReload to true in the strapi instance the content type builder is again enable:

const tsConfig = require('../../../../tsconfig.base.json')
const tsConfigPaths = require('tsconfig-paths')
const strapi = require('@strapi/strapi')
const path = require('path')
const { buildAdmin } = require('@strapi/strapi/lib/commands/builders')
const tsUtils = require('@strapi/typescript-utils')

const appName = 'cms'
const strapiRoot = path.join(__dirname, '..')
const distDirRoot = path.join(strapiRoot, 'dist')
const distDirApp = path.join(distDirRoot, 'apps', 'bullflix', appName)

console.log({ strapiRoot, distDirRoot, distDirApp })

for (const key in tsConfig.compilerOptions.paths) {
  const pathStr = tsConfig.compilerOptions.paths[key][0]
  const newPath = path.parse(pathStr)
  const pathWithoutExtension = path.join(newPath.dir, newPath.name)

  tsConfig.compilerOptions.paths[key] = [pathWithoutExtension]
}

console.log(tsConfig.compilerOptions.paths)

tsConfigPaths.register({
  baseUrl: distDirRoot,
  paths: tsConfig.compilerOptions.paths,
})
;(async () => {
  await tsUtils.compile(strapiRoot, { watch: false })

  await buildAdmin({ forceBuild: true, buildDestDir: distDirApp, srcDir: strapiRoot })

  const app = strapi({ appDir: strapiRoot, distDir: distDirApp, autoReload: true }) // <--
  app.start()
})()

After save some content type update the process dies:

/Users/nacho/Code/bullflix/bullflix/apps/bullflix/cms/node_modules/@strapi/strapi/lib/Strapi.js:514
        process.send('reload');
                ^
TypeError: process.send is not a function
    at Strapi.reload (/Users/nacho/Code/bullflix/bullflix/apps/bullflix/cms/node_modules/@strapi/strapi/lib/Strapi.js:514:17)
    at Immediate.<anonymous> (/Users/nacho/Code/bullflix/bullflix/apps/bullflix/cms/node_modules/@strapi/plugin-content-type-builder/server/controllers/content-types.js:116:33)
    at process.processImmediate (node:internal/timers:476:21)
    at process.callbackTrampoline (node:internal/async_hooks:130:17)
Node.js v18.16.0
Warning: run-commands command "node apps/bullflix/cms/scripts/serve.js" exited with non-zero status code

I suppose this is normal because strapi use internally the node clusters feature and now the process is managed by us. Simply start again the server after crash works.

icastillejogomez commented 1 year ago

After some time trying to replicate the strapi develop script I found the way the server restarts automatically:

const cluster = require('cluster')
const tsConfig = require('../../../../tsconfig.base.json')
const tsConfigPaths = require('tsconfig-paths')
const strapi = require('@strapi/strapi')
const path = require('path')
const { buildAdmin } = require('@strapi/strapi/lib/commands/builders')
const tsUtils = require('@strapi/typescript-utils')

const appName = 'cms'
const strapiRoot = path.join(__dirname, '..')
const distDirRoot = path.join(strapiRoot, 'dist')
const distDirApp = path.join(distDirRoot, 'apps', 'bullflix', appName)

// Update tsConfig paths to remove .ts extensions
updateTsConfigPaths(tsConfig)

function updateTsConfigPaths(tsConfig) {
  for (const key in tsConfig.compilerOptions.paths) {
    const pathStr = tsConfig.compilerOptions.paths[key][0]
    const newPath = path.parse(pathStr)
    const pathWithoutExtension = path.join(newPath.dir, newPath.name)

    tsConfig.compilerOptions.paths[key] = [pathWithoutExtension]
  }

  tsConfigPaths.register({
    baseUrl: distDirRoot,
    paths: tsConfig.compilerOptions.paths,
  })

  return tsConfig
}

async function buildTypescript() {
  await tsUtils.compile(strapiRoot, { watch: false })
}

async function buildAdminPanel() {
  await buildAdmin({ forceBuild: true, buildDestDir: distDirApp, srcDir: strapiRoot })
}

async function main() {
  try {
    if (cluster.isPrimary) {
      return primaryProcess()
    }

    if (cluster.isWorker) {
      return workerProcess()
    }
  } catch (error) {
    console.error(error)
    process.exit(1)
  }
}

async function primaryProcess() {
  await buildTypescript()
  await buildAdminPanel()

  cluster.on('message', async (worker, message) => {
    switch (message) {
      case 'reload':
        await buildTypescript()
        console.info('The server is restarting\n')
        worker.send('kill')
        break
      case 'killed':
        cluster.fork()
        break
      case 'stop':
        process.exit(1)
        break
      default: {
        break
      }
    }
  })

  cluster.fork()
}

async function workerProcess() {
  const app = strapi({ appDir: strapiRoot, distDir: distDirApp, autoReload: true })

  process.on('message', async (message) => {
    switch (message) {
      case 'kill': {
        await app.destroy()
        process.send('killed')
        process.exit()
        break
      }
      default: {
        break
      }
      // Do nothing.
    }
  })

  app.start()
}

// Run the cluster
main().catch((error) => {
  console.error(error)
  throw error
})
icastillejogomez commented 1 year ago

Seeing the develop strapi script It's possible to run with watch mode. Here is the script with watch mode and restart capabilities:

const cluster = require('cluster')
const tsConfig = require('../../../../tsconfig.base.json')
const tsConfigPaths = require('tsconfig-paths')
const strapi = require('@strapi/strapi')
const chokidar = require('chokidar')
const path = require('path')
const { buildAdmin } = require('@strapi/strapi/lib/commands/builders')
const tsUtils = require('@strapi/typescript-utils')

const appName = 'cms'
const strapiRoot = path.join(__dirname, '..')
const distDirRoot = path.join(strapiRoot, 'dist')
const distDirApp = path.join(distDirRoot, 'apps', 'bullflix', appName)

console.log({
  strapiRoot,
  distDirRoot,
  distDirApp,
})

// Update tsConfig paths to remove .ts extensions
updateTsConfigPaths(tsConfig)

function updateTsConfigPaths(tsConfig) {
  for (const key in tsConfig.compilerOptions.paths) {
    const pathStr = tsConfig.compilerOptions.paths[key][0]
    const newPath = path.parse(pathStr)
    const pathWithoutExtension = path.join(newPath.dir, newPath.name)

    tsConfig.compilerOptions.paths[key] = [pathWithoutExtension]
  }

  tsConfigPaths.register({
    baseUrl: distDirRoot,
    paths: tsConfig.compilerOptions.paths,
  })

  return tsConfig
}

async function buildTypescript() {
  await tsUtils.compile(strapiRoot, { watch: false })
}

async function buildAdminPanel() {
  await buildAdmin({ forceBuild: true, buildDestDir: distDirApp, srcDir: strapiRoot })
}

function watchFileChanges({ appDir, strapiInstance }) {
  const restart = async () => {
    if (strapiInstance.reload.isWatching && !strapiInstance.reload.isReloading) {
      strapiInstance.reload.isReloading = true
      strapiInstance.reload()
    }
  }

  const watcher = chokidar.watch(appDir, {
    ignoreInitial: true,
    usePolling: false,
    ignored: [
      /(^|[/\\])\../, // dot files
      /tmp/,
      '**/src/admin/**',
      '**/src/plugins/**/admin/**',
      '**/dist/src/plugins/test/admin/**',
      '**/documentation',
      '**/documentation/**',
      '**/node_modules',
      '**/node_modules/**',
      '**/plugins.json',
      '**/build',
      '**/build/**',
      '**/index.html',
      '**/public',
      '**/public/**',
      distDirRoot,
      path.join(appDir, 'scripts', '**'),
      strapiInstance.dirs.static.public,
      path.join('/', strapiInstance.dirs.static.public, '**'),
      '**/*.db*',
      '**/exports/**',
      '**/dist/**',
    ],
  })

  watcher
    .on('add', (path) => {
      strapiInstance.log.info(`File created: ${path}`)
      restart()
    })
    .on('change', (path) => {
      strapiInstance.log.info(`File changed: ${path}`)
      restart()
    })
    .on('unlink', (path) => {
      strapiInstance.log.info(`File deleted: ${path}`)
      restart()
    })
}

async function main() {
  try {
    if (cluster.isPrimary) {
      return primaryProcess()
    }

    if (cluster.isWorker) {
      return workerProcess()
    }
  } catch (error) {
    console.error(error)
    process.exit(1)
  }
}

async function primaryProcess() {
  await buildTypescript()
  await buildAdminPanel()

  cluster.on('message', async (worker, message) => {
    switch (message) {
      case 'reload':
        await buildTypescript()
        console.info('The server is restarting\n')
        worker.send('kill')
        break
      case 'killed':
        cluster.fork()
        break
      case 'stop':
        process.exit(1)
        break
      default: {
        break
      }
    }
  })

  cluster.fork()
}

async function workerProcess() {
  const app = strapi({ appDir: strapiRoot, distDir: distDirApp, autoReload: true })

  watchFileChanges({ appDir: strapiRoot, strapiInstance: app })

  process.on('message', async (message) => {
    switch (message) {
      case 'kill': {
        await app.destroy()
        process.send('killed')
        process.exit()
        break
      }
      default: {
        break
      }
      // Do nothing.
    }
  })

  app.start()
}

// Run the cluster
main().catch((error) => {
  console.error(error)
  throw error
})
TriPSs commented 1 year ago

@icastillejogomez where you able to produce a actual prod build with the scripts? The issues that I encountered was once I create a prod build it was unable to start as the build output was completely wrong.