Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using a shared node module for common classes

Goal

So I am having a project with this structure:

  • ionic-app
  • firebase-functions
  • shared

The goal is to define common interfaces and classes in the shared module.

Restrictions

I don't want to upload my code to npm to use it locally and am not planning on uploading the code at all. It should 100% work offline.

While the development process should work offline, the ionic-app and firebase-functions modules are going to be deployed to firebase (hosting & functions). Therefore, the code from the shared module should be available there.

What I have tried so far

  • I have tried using Project References in typescript, but I have not gotten it close to working
  • I tried it with installing it as an npm module like in the second answer of this question
    • It seems to be working fine at first, but during the build, I get an error like this when running firebase deploy:
Function failed on loading user code. Error message: Code in file lib/index.js can't be loaded.
Did you list all required modules in the package.json dependencies?
Detailed stack trace: Error: Cannot find module 'shared'
    at Function.Module._resolveFilename (module.js:548:15)
    at Function.Module._load (module.js:475:25)
    at Module.require (module.js:597:17)
    at require (internal/module.js:11:18)
    at Object.<anonymous> (/srv/lib/index.js:5:18)

Question

Do you have a solution for making a shared module using either typescripts config, or NPM?

Please do not mark this as a duplicate → I have tried any solution I have found on StackOverflow.

Additional Info

Config for shared:

// package.json
{
  "name": "shared",
  "version": "1.0.0",
  "description": "",
  "main": "dist/src/index.js",
  "types": "dist/src/index.d.ts",
  "files": [
    "dist/src/**/*"
  ],
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "publishConfig": {
    "access": "private"
  }
}

// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "rootDir": ".",
    "sourceRoot": "src",
    "outDir": "dist",
    "sourceMap": true,
    "declaration": true,
    "target": "es2017"
  }
}

Config for functions:

// package.json
{
  "name": "functions",
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
    "serve": "npm run build && firebase serve --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "8"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^8.0.0",
    "firebase-functions": "^3.1.0",
    "shared": "file:../../shared"
  },
  "devDependencies": {
    "@types/braintree": "^2.20.0",
    "tslint": "^5.12.0",
    "typescript": "^3.2.2"
  },
  "private": true
}


// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./",
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": false,
    "rootDir": "src",
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017"
  }
}

Current soution

I have added a npm script to the shared module, which copies all files (without the index.js) to the other modules. This has the problem, that I check in duplicate code into SCM, and that I need to run that command on every change. Also, the IDE just treats it as different files.

like image 475
MauriceNino Avatar asked Oct 30 '19 19:10

MauriceNino


People also ask

CAN node modules be shared?

Option 1: Link to a Local Project Folder Once you've moved your shared code into a separate project, link the project as a dependency using npm link. Note: The shared library can be maintained in a separate repository or the same repository as your other projects (a.k.a, monorepo).

Can I copy node_modules folder to another project?

Yes you can copy whole node_modules (have done it multiple times) from one project to another and use same package.

Do I need to install node modules every time?

We do not need to install a module every time when installed globally. It takes less memory as only one copy is installed. We can make . js scripts and run them anywhere without having a node_modules folder in the same directory when packages are installed globally.


Video Answer


2 Answers

Preface: I'm not too familiar with how Typescript compilation works and how package.json in such a module should be defined. This solution, although it works, could be considered a hacky way of achieving the task at hand.

Assuming the following directory structure:

project/
  ionic-app/
    package.json
  functions/
    src/
      index.ts
    lib/
      index.js
    package.json
  shared/
    src/
      shared.ts
    lib/
      shared.js
    package.json

When deploying a Firebase service, you can attach commands to the predeploy and postdeploy hooks. This is done in firebase.json via the properties predeploy and postdeploy on the desired service. These properties contain an array of sequential commands to run before and after deploying your code respectively. Furthermore, these commands are called with the environment variables RESOURCE_DIR (the directory path of ./functions or ./ionic-app, whichever is applicable) and PROJECT_DIR (the directory path containing firebase.json).

Using the predeploy array for functions inside firebase.json, we can copy the shared library's code into the folder that is deployed to the Cloud Functions instance. By doing this, you can simply include the shared code as if it were a library located in a subfolder or you can map it's name using Typescript's path mapping in tsconfig.json to a named module (so you can use import { hiThere } from 'shared';).

The predeploy hook definition (uses global install of shx for Windows compatibility):

// firebase.json
{
  "functions": {
    "predeploy": [
      "shx rm -rf \"$RESOURCE_DIR/src/shared\"", // delete existing files
      "shx cp -R \"$PROJECT_DIR/shared/.\" \"$RESOURCE_DIR/src/shared\"", // copy latest version
      "npm --prefix \"$RESOURCE_DIR\" run lint", // lint & compile
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  },
  "hosting": {
    "public": "ionic-app",
    ...
  }
}

Linking the copied library's typescript source to the functions typescript compiler config:

// functions/tsconfig.json
{
  "compilerOptions": {
    ...,
    "baseUrl": "./src",
    "paths": {
      "shared": ["shared/src"]
    }
  },
  "include": [
    "src"
  ],
  ...
}

Associating the module name, "shared", to the copied library's package folder.

// functions/package.json
{
  "name": "functions",
  "scripts": {
    ...
  },
  "engines": {
    "node": "8"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^8.6.0",
    "firebase-functions": "^3.3.0",
    "shared": "file:./src/shared",
    ...
  },
  "devDependencies": {
    "tslint": "^5.12.0",
    "typescript": "^3.2.2",
    "firebase-functions-test": "^0.1.6"
  },
  "private": true
}

The same approach can be used with the hosting folder.


Hopefully this inspires someone who is more familiar with Typescript compilation to come up with a cleaner solution that makes use of these hooks.
like image 175
samthecodingman Avatar answered Oct 17 '22 17:10

samthecodingman


You might want to try Lerna, a tool for managing JavaScript (and TypeScript) projects with multiple packages.

Setup

Assuming that your project has the following directory structure:

packages
  ionic-app
    package.json
  firebase-functions
    package.json
  shared
    package.json

Make sure to specify the correct access level (private and config/access keys) in all the modules you don't wish to have published, as well as the typings entry in your shared module:

Shared:

{
  "name": "shared",
  "version": "1.0.0",
  "private": true,
  "config": {
    "access": "private"
  },
  "main": "lib/index.js",
  "typings": "lib/index.d.ts",
  "scripts": {
    "compile": "tsc --project tsconfig.json"
  }
}

Ionic-app:

{
  "name": "ionic-app",
  "version": "1.0.0",
  "private": true,
  "config": {
    "access": "private"
  },
  "main": "lib/index.js",
  "scripts": {
    "compile": "tsc --project tsconfig.json"
  },
  "dependencies": {
    "shared": "1.0.0"
  }
}

With the above changes in place, you can create a root-level package.json where you can specify any devDependencies that you wish all your project modules to have access to, such as your unit testing framework, tslint, etc.

packages
  ionic-app
    package.json
  firebase-functions
    package.json
  shared
    package.json
package.json         // root-level, same as the `packages` dir

You can also use this root-level package.json to define npm scripts that will invoke the corresponding scripts in your project's modules (via lerna):

{
  "name": "my-project",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "compile": "lerna run compile --stream",
    "postinstall": "lerna bootstrap",
  },
  "devDependencies": {
    "lerna": "^3.18.4",
    "tslint": "^5.20.1",
    "typescript": "^3.7.2"
  },
}

With that in place, add the lerna config file in your root directory:

packages
  ionic-app
    package.json
  firebase-functions
    package.json
  shared
    package.json
package.json
lerna.json

with following contents:

{
  "lerna": "3.18.4",
  "loglevel": "info",
  "packages": [
    "packages/*"
  ],
  "version": "1.0.0"
}

Now when you run npm install in the root directory, the postinstall script defined in your root-level package.json will invoke lerna bootstrap.

What lerna bootstrap does is that it will symlink your shared module to ionic-app/node_modules/shared and firebase-functions/node_modules/shared, so from the point of those two modules, shared looks just like any other npm module.

Compilation

Of course, symlinking the modules is not enough as you still need to compile them from TypeScript to JavaScript.

That's where the root-level package.json compile script comes into play.

When you run npm run compile in your project root, npm will invoke lerna run compile --stream, and lerna run compile --stream invokes the script called compile in each of your modules' package.json file.

Since each of your modules now has its own compile script, you should can have a tsonfig.json file per module. If you don't like the duplication you could get away with a root-level tsconfig, or a combination of a root-level tsconfig and module-level tsconfig files inheriting from the root one.

If you'd like to see how this setup works on a real-world project, have a look at Serenity/JS where I've been using it quite extensively.

Deployment

The nice thing about having the shared module symlinked under node_modules under firebase-functions and ionic-app, and your devDepedencies under node_modules under project root is that if you need to deploy the consumer module anywhere (so the ionic-app for example), you could just zip it all up together with its node_modules and not worry about having to remove the dev depedencies prior to deployment.

Hope this helps!

Jan

like image 37
Jan Molak Avatar answered Oct 17 '22 18:10

Jan Molak