A couple years ago I wrote a piece about sharing code in Typescript monorepos, focusing mainly on npm workspaces, and I left out an important aspect of the software development lifecycle: shipping the compiled files to production environments. Because that is such a fundamental part of software development, I'll be addressing it in this second piece of writing.
Having successfully extracted the duplicate code into a separate directory/npm project (covered in sharing code in Typescript monorepos), the task at hand is to decide how the shared code and its external dependencies will make their way to production environments. Let's consider two different solutions: one based on relative path imports and another based on npm dependencies.
Note that the distribution issue is mostly a server side concern since client apps are usually bundled into static assets that include both external dependencies and code imported from outside the project.
To make the explanations more tangible, let's introduce a simple monorepo example. For the sake of simplicity the example code has no actual functionality, just one duplicate function (getId) that relies on one external dependency (nanoid). Here is the monorepo structure:
π client
| π source
| | π index.html
| | π index.tsx
| π package.json
| π tsconfig.json
| π webpack.config.js
π server
π public
π source
| π main.ts
π Dockerfile
π package.json
π tsconfig.json
The server side code:
And the client side code:
Relative path imports
When importing shared code via relative path imports, Typescript will include the shared code into the project's compiled files. Distribution problem solved! But there's a caveat: importing code from outside the project directory via relative paths will impact the way Typescript compiles the files.
Because we are now importing code from outside the project's rootDir, Typescript will need to create additional directories inside the compiled files directory. In our example the directory structure will change from
π server
π dist
| π main.js
π source
π main.ts
to:
π shared
| π index.ts
π server
π dist
| π server
| | π source
| | π main.js
| π shared
| π index.js
π source
π main.ts
Depending on the Typescript version you use, importing code from outside the project directory might result in the following error. To fix it, simply change the rootDir property to the path of the directory that contains all projects (usually, "..").
import { getId } from "../../shared";
This consequence comes from the fact that Typescript doesn't change import paths during compilation. Therefore the only way to guarantee the paths are still correct after compilation is to replicate the same directory structure. The new directory structure is not necessarily a deal breaker, but it could be when using non-import relative paths (e.g. resolve(__dirname, "..", "public")).
A simple solution is to make the non-imports paths relative to the distribution directory. In our example: resolve(__dirname, "..", "..", "..", "public"). Another solution, for cases where development tools are used to run the project without compiling (i.e. ts-node), would be to set those paths depending on the execution mode.
And what about the external dependencies of the shared code? Well, since the shared code is not an npm project, it can't manage its own dependencies. Each project that imports from it is responsible for installing any dependencies the shared code might need. It means having the same dependencies declared across different package.json files but, hey, they were already there before the code extraction, so it's not a big deal. If you want to manage the external dependencies of the shared code from a single source, then the npm dependencies approach will work better for you.
Summary:
- π The compiled files include the shared code.
- π Might break non-import relative paths.
- π Duplicate dependencies across different package.json files.
Changes: https://github.com/capelski/typescript-monorepo/compare/main...relative-path-imports
npm dependencies
When importing shared code via npm dependencies (local or public), the shared code becomes an independent npm project, so it can manage its own dependencies. One problem solved! Now, what about the distribution of the compiled files?
When opting for public dependencies, the shared code will be provisioned via the dependencies installation step (executed either directly on the production environments or via Docker image). Note however that public dependencies involve additional overhead (e.g. publishing the dependency to a package repository). For the sake of simplicity, let's consider local dependencies this time.
When opting for local dependencies, the shared code will no longer be provisioned via the dependencies installation step. This happens because local dependencies are just a symbolic link inside the node_modules directory, which rely on the shared code being present in the workspace. Because the shared code will not be present by default in the production environment, we will need to provide it separately.
This is what happens in our example when we try to run the Dockerized application after having extracted the duplicate code into an npm local dependency:
> server@1.0.0 start
> node dist/main.js
node:internal/modules/cjs/loader:1042
βthrow err;
β^
Error: Cannot find module 'shared'
Require stack:
- /usr/src/app/dist/main.js
ββat Module._resolveFilename (node:internal/modules/cjs/loader:1039:15)
ββat Module._load (node:internal/modules/cjs/loader:885:27)
ββat Module.require (node:internal/modules/cjs/loader:1105:19)
ββat require (node:internal/modules/cjs/helpers:103:18)
ββat Object.<anonymous> (/usr/src/app/dist/main.js:8:18)
ββat Module._compile (node:internal/modules/cjs/loader:1218:14)
ββat Module._extensions..js (node:internal/modules/cjs/loader:1272:10)
ββat Module.load (node:internal/modules/cjs/loader:1081:32)
ββat Module._load (node:internal/modules/cjs/loader:922:12)
ββat Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) {
βcode: 'MODULE_NOT_FOUND',
βrequireStack: [ '/usr/src/app/dist/main.js' ]
}
To fix this problem we will need to explicitly provision the shared code npm project, independently from the external dependencies installation. For Dockerizied apps, one way to do so consists in including the npm project in the Docker image of the projects that import it, using the same relative path.
In our example, this means changing the Docker build command to start from the parent directory (as it will now need access to both the server and shared projects' directory) and turning the Dockerfile (simplified) from
WORKDIR /usr/src/app
COPY . .
RUN npm ci --omit=dev
EXPOSE 3000
CMD npm start
# Copy shared code and install its dependencies
WORKDIR /usr/src/shared
COPY ./shared .
RUN npm ci --omit=dev
WORKDIR /usr/src/app
COPY ./server . # The path must now be relative to the parent directory
RUN npm ci --omit=dev
EXPOSE 3000
CMD npm start
Summary:
- π The shared code dependencies are managed in a single package.json file.
- π Additional effort required to distribute the shared code.
Changes: https://github.com/capelski/typescript-monorepo/compare/main...npm-dependencies